@yemi33/minions 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +819 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -0
  4. package/agents/dallas/charter.md +56 -0
  5. package/agents/lambert/charter.md +67 -0
  6. package/agents/ralph/charter.md +45 -0
  7. package/agents/rebecca/charter.md +57 -0
  8. package/agents/ripley/charter.md +47 -0
  9. package/bin/minions.js +467 -0
  10. package/config.template.json +28 -0
  11. package/dashboard.html +4822 -0
  12. package/dashboard.js +2623 -0
  13. package/docs/auto-discovery.md +416 -0
  14. package/docs/blog-first-successful-dispatch.md +128 -0
  15. package/docs/command-center.md +156 -0
  16. package/docs/demo/01-dashboard-overview.gif +0 -0
  17. package/docs/demo/02-command-center.gif +0 -0
  18. package/docs/demo/03-work-items.gif +0 -0
  19. package/docs/demo/04-plan-docchat.gif +0 -0
  20. package/docs/demo/05-prd-progress.gif +0 -0
  21. package/docs/demo/06-inbox-metrics.gif +0 -0
  22. package/docs/deprecated.json +83 -0
  23. package/docs/distribution.md +96 -0
  24. package/docs/engine-restart.md +92 -0
  25. package/docs/human-vs-automated.md +108 -0
  26. package/docs/index.html +221 -0
  27. package/docs/plan-lifecycle.md +140 -0
  28. package/docs/self-improvement.md +344 -0
  29. package/engine/ado-mcp-wrapper.js +42 -0
  30. package/engine/ado.js +383 -0
  31. package/engine/check-status.js +23 -0
  32. package/engine/cli.js +754 -0
  33. package/engine/consolidation.js +417 -0
  34. package/engine/github.js +331 -0
  35. package/engine/lifecycle.js +1113 -0
  36. package/engine/llm.js +116 -0
  37. package/engine/queries.js +677 -0
  38. package/engine/shared.js +397 -0
  39. package/engine/spawn-agent.js +151 -0
  40. package/engine.js +3227 -0
  41. package/minions.js +556 -0
  42. package/package.json +48 -0
  43. package/playbooks/ask.md +49 -0
  44. package/playbooks/build-and-test.md +155 -0
  45. package/playbooks/explore.md +64 -0
  46. package/playbooks/fix.md +57 -0
  47. package/playbooks/implement-shared.md +68 -0
  48. package/playbooks/implement.md +95 -0
  49. package/playbooks/plan-to-prd.md +104 -0
  50. package/playbooks/plan.md +99 -0
  51. package/playbooks/review.md +68 -0
  52. package/playbooks/test.md +75 -0
  53. package/playbooks/verify.md +190 -0
  54. package/playbooks/work-item.md +74 -0
package/dashboard.js ADDED
@@ -0,0 +1,2623 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Minions Mission Control Dashboard
4
+ * Run: node .minions/dashboard.js
5
+ * Opens: http://localhost:7331
6
+ */
7
+
8
+ const http = require('http');
9
+ const zlib = require('zlib');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const llm = require('./engine/llm');
13
+ const shared = require('./engine/shared');
14
+ const queries = require('./engine/queries');
15
+ const os = require('os');
16
+
17
+ const { safeRead, safeReadDir, safeWrite, safeJson, safeUnlink, mutateJsonFileLocked, getProjects: _getProjects } = shared;
18
+ const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
19
+ getSkills, getInbox, getNotesWithMeta, getPullRequests,
20
+ getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
21
+ MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, DISPATCH_PATH, PRD_DIR } = queries;
22
+
23
+ const PORT = parseInt(process.env.PORT || process.argv[2]) || 7331;
24
+ let CONFIG = queries.getConfig();
25
+ let PROJECTS = _getProjects(CONFIG);
26
+ let projectNames = PROJECTS.map(p => p.name || 'Project').join(' + ');
27
+
28
+ function reloadConfig() {
29
+ CONFIG = queries.getConfig();
30
+ PROJECTS = _getProjects(CONFIG);
31
+ projectNames = PROJECTS.map(p => p.name || 'Project').join(' + ');
32
+ }
33
+
34
+ const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
35
+
36
+ // Resolve a plan/PRD file path: .json files live in prd/, .md files in plans/
37
+ function resolvePlanPath(file) {
38
+ if (file.endsWith('.json')) {
39
+ const active = path.join(PRD_DIR, file);
40
+ if (fs.existsSync(active)) return active;
41
+ const archived = path.join(PRD_DIR, 'archive', file);
42
+ if (fs.existsSync(archived)) return archived;
43
+ return active;
44
+ }
45
+ const active = path.join(PLANS_DIR, file);
46
+ if (fs.existsSync(active)) return active;
47
+ const archived = path.join(PLANS_DIR, 'archive', file);
48
+ if (fs.existsSync(archived)) return archived;
49
+ return active;
50
+ }
51
+
52
+ const HTML_RAW = safeRead(path.join(MINIONS_DIR, 'dashboard.html')) || '';
53
+ const HTML = HTML_RAW.replace('Minions Mission Control', `Minions Mission Control — ${projectNames}`);
54
+ const HTML_GZ = zlib.gzipSync(HTML);
55
+ const HTML_ETAG = '"' + require('crypto').createHash('md5').update(HTML).digest('hex') + '"';
56
+
57
+
58
+ // -- Data Collectors (most moved to engine/queries.js) --
59
+
60
+ function getVerifyGuides() {
61
+ const guidesDir = path.join(MINIONS_DIR, 'prd', 'guides');
62
+ const guides = [];
63
+ try {
64
+ const files = safeReadDir(guidesDir).filter(f => f.endsWith('.md'));
65
+ for (const f of files) {
66
+ // Match guide to plan: verify-officeagent-2026-03-15.md → officeagent-2026-03-15.json
67
+ const planSlug = f.replace('verify-', '').replace('.md', '');
68
+ const planFile = planSlug + '.json';
69
+ guides.push({ file: f, planFile });
70
+ }
71
+ } catch {}
72
+ return guides;
73
+ }
74
+
75
+ function getArchivedPrds() { return []; }
76
+ function getEngineState() { return queries.getControl(); }
77
+
78
+ function getMcpServers() {
79
+ try {
80
+ const home = process.env.USERPROFILE || process.env.HOME || '';
81
+ const claudeJsonPath = path.join(home, '.claude.json');
82
+ const data = JSON.parse(safeRead(claudeJsonPath) || '{}');
83
+ const servers = data.mcpServers || {};
84
+ return Object.entries(servers).map(([name, cfg]) => ({
85
+ name,
86
+ command: cfg.command || '',
87
+ args: (cfg.args || []).slice(-1)[0] || '',
88
+ }));
89
+ } catch { return []; }
90
+ }
91
+
92
+ let _statusCache = null;
93
+ let _statusCacheTs = 0;
94
+ const STATUS_CACHE_TTL = 10000; // 10s — reduces expensive aggregation frequency; mutations call invalidateStatusCache()
95
+ function invalidateStatusCache() { _statusCache = null; }
96
+
97
+ function getStatus() {
98
+ const now = Date.now();
99
+ if (_statusCache && (now - _statusCacheTs) < STATUS_CACHE_TTL) return _statusCache;
100
+
101
+ // Reload config on each cache miss — picks up external changes (minions init, minions add)
102
+ reloadConfig();
103
+
104
+ const prdInfo = getPrdInfo();
105
+ _statusCache = {
106
+ agents: getAgents(),
107
+ prdProgress: prdInfo.progress,
108
+ inbox: getInbox(),
109
+ notes: getNotesWithMeta(),
110
+ prd: prdInfo.status,
111
+ pullRequests: getPullRequests(),
112
+ verifyGuides: getVerifyGuides(),
113
+ archivedPrds: getArchivedPrds(),
114
+ engine: getEngineState(),
115
+ dispatch: getDispatchQueue(),
116
+ engineLog: getEngineLog(),
117
+ metrics: getMetrics(),
118
+ workItems: getWorkItems(),
119
+ skills: getSkills(),
120
+ mcpServers: getMcpServers(),
121
+ projects: PROJECTS.map(p => ({ name: p.name, path: p.localPath, description: p.description || '' })),
122
+ initialized: !!(CONFIG.agents && Object.keys(CONFIG.agents).length > 0),
123
+ installId: safeRead(path.join(MINIONS_DIR, '.install-id')).trim() || null,
124
+ timestamp: new Date().toISOString(),
125
+ };
126
+ _statusCacheTs = now;
127
+ return _statusCache;
128
+ }
129
+
130
+
131
+ // ── Command Center: session state + helpers ─────────────────────────────────
132
+
133
+ const CC_SESSION_EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
134
+ const CC_SESSION_MAX_TURNS = 50;
135
+ let ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
136
+ let ccInFlight = false;
137
+ let ccInFlightSince = 0; // timestamp — auto-release stuck guard
138
+ const CC_INFLIGHT_TIMEOUT_MS = 11 * 60 * 1000; // 11 minutes (slightly > LLM timeout)
139
+
140
+ function ccSessionValid() {
141
+ if (!ccSession.sessionId) return false;
142
+ const age = Date.now() - new Date(ccSession.lastActiveAt || 0).getTime();
143
+ return age < CC_SESSION_EXPIRY_MS && ccSession.turnCount < CC_SESSION_MAX_TURNS;
144
+ }
145
+
146
+ // Load persisted CC session on startup
147
+ try {
148
+ const saved = safeJson(path.join(ENGINE_DIR, 'cc-session.json'));
149
+ if (saved && saved.sessionId) {
150
+ const age = Date.now() - new Date(saved.lastActiveAt || 0).getTime();
151
+ if (age < CC_SESSION_EXPIRY_MS) ccSession = saved;
152
+ }
153
+ } catch {}
154
+
155
+ // Static system prompt — baked into session on creation, never changes
156
+ const CC_STATIC_SYSTEM_PROMPT = `You are the Command Center AI for a software engineering minions called "Minions."
157
+ You have full CLI-level power — you can read, write, edit files, run shell commands, and execute builds just like a Claude Code CLI session. You also have minions-specific actions to delegate work to agents.
158
+
159
+ ## Guardrails — What You Must NOT Touch
160
+
161
+ These files are the live engine. Modifying them can crash the minions or corrupt state:
162
+ - \`${MINIONS_DIR}/engine.js\` and \`${MINIONS_DIR}/engine/*.js\` — engine source code
163
+ - \`${MINIONS_DIR}/dashboard.js\` and \`${MINIONS_DIR}/dashboard.html\` — dashboard source
164
+ - \`${MINIONS_DIR}/minions.js\` and \`${MINIONS_DIR}/bin/*.js\` — CLI source
165
+ - \`${MINIONS_DIR}/engine/control.json\` — engine state (use CLI commands instead)
166
+ - \`${MINIONS_DIR}/engine/dispatch.json\` — dispatch queue (use actions instead)
167
+ - \`${MINIONS_DIR}/config.json\` — engine config (use actions or CLI instead)
168
+
169
+ You CAN freely read any of these files. You just must not write/edit them.
170
+
171
+ You CAN freely modify: notes, plans, knowledge base, work items, pull-requests.json, routing.md, agent charters, skills, playbooks, and anything in project repos.
172
+
173
+ ## Filesystem — What's Where
174
+
175
+ \`\`\`
176
+ ${MINIONS_DIR}/
177
+ ├── config.json # Engine + project config (READ ONLY)
178
+ ├── routing.md # Agent dispatch routing rules
179
+ ├── notes.md # Consolidated team notes
180
+ ├── work-items.json # Central work item queue (cross-project tasks)
181
+ ├── projects/{name}/ # Per-project state (centralized, NOT in project repos)
182
+ │ ├── work-items.json # Project-specific work items
183
+ │ └── pull-requests.json # PR tracking for this project
184
+ ├── agents/
185
+ │ ├── {id}/charter.md # Agent role definition
186
+ │ ├── {id}/output.log # Latest agent output
187
+ │ └── {id}/output-{dispatchId}.log # Archived outputs
188
+ ├── plans/ # Source plans (.md)
189
+ ├── prd/ # PRD items (.json), prd/archive/, prd/guides/
190
+ ├── knowledge/{category}/*.md # Knowledge base
191
+ ├── engine/ # Engine internals (READ ONLY)
192
+ │ ├── dispatch.json # Pending, active, completed dispatches
193
+ │ ├── metrics.json # Token/cost tracking
194
+ │ ├── control.json # Engine state (running/paused/stopped)
195
+ │ └── cooldowns.json # Dispatch cooldown timers
196
+ ├── playbooks/*.md # Task templates
197
+ ├── skills/*.md # Reusable agent workflow definitions
198
+ └── notes/inbox/*.md # Unconsolidated agent findings
199
+ \`\`\`
200
+
201
+ Projects are configured in \`config.json\` under \`projects[]\`. Per-project state lives centrally in \`${MINIONS_DIR}/projects/{name}/\` — NOT inside project repos. There are no \`.minions/\` folders inside project repos.
202
+
203
+ ## Direct Execution
204
+
205
+ You have Bash, Write, Edit, and all standard tools. Use them directly when the task is straightforward:
206
+ - **Build & run projects** — \`cd <project> && npm install && npm run dev\`
207
+ - **Inspect code** — read files, grep, explore
208
+ - **Edit project files** — fix configs, update docs, tweak settings
209
+ - **Git operations** — fetch, checkout, merge, diff (but do NOT push without the user confirming)
210
+ - **Start dev servers** — for long-running servers, use detached processes so they survive after you finish
211
+
212
+ **When to do it yourself vs delegate to an agent:**
213
+ - Quick, one-shot tasks (build, read, check, install, start a server) → do it yourself
214
+ - Complex multi-file code changes, PR creation, code review → dispatch to an agent
215
+ - Anything that needs deep codebase knowledge or iterative coding → dispatch to an agent
216
+
217
+ ## Minions Actions (Delegation)
218
+
219
+ When you want to delegate work to agents, append actions at the END of your response.
220
+
221
+ **Format:** Write your conversational response first, then on a new line write exactly \`===ACTIONS===\` followed by a JSON array of actions. Example:
222
+
223
+ I'll save that as a note and dispatch dallas to fix the bug.
224
+
225
+ ===ACTIONS===
226
+ [{"type": "note", "title": "API v3 migration needed", "content": "We need to migrate..."}, {"type": "dispatch", "title": "Fix login bug", "workType": "fix", "priority": "high", "agents": ["dallas"], "project": "OfficeAgent", "description": "..."}]
227
+
228
+ **CRITICAL:** The ===ACTIONS=== line and JSON array must be the LAST thing in your response. No text after it. The JSON must be a valid array on a single line.
229
+
230
+ If no actions are needed (just answering a question, or you handled it directly), do NOT include the ===ACTIONS=== line.
231
+
232
+ Available action types:
233
+ - **dispatch**: Create a work item for an agent. Fields: title, workType (ask/explore/fix/review/test/implement/verify), priority (low/medium/high), agents (array of IDs, optional), project, description. Use \`verify\` when the user wants to build PRs locally, merge branches together, start a dev server, and get a localhost URL to test.
234
+ - **note**: Save a note/decision. Fields: title, content
235
+ - **plan**: Create a multi-step plan. Fields: title, description, project, branchStrategy (parallel/shared-branch)
236
+ - **cancel**: Cancel a running agent. Fields: agent (agent ID), reason
237
+ - **retry**: Retry failed work items. Fields: ids (array of work item IDs)
238
+ - **pause-plan**: Pause a PRD (stop materializing items). Fields: file (PRD .json filename)
239
+ - **approve-plan**: Approve a PRD (start materializing items). Fields: file (PRD .json filename)
240
+ - **edit-prd-item**: Edit a PRD item. Fields: source (PRD filename), itemId, name, description, priority, complexity
241
+ - **remove-prd-item**: Remove a PRD item. Fields: source (PRD filename), itemId
242
+ - **delete-work-item**: Delete a work item. Fields: id, source (project name or "central")
243
+ - **plan-edit**: Revise/edit a plan .md file. Fields: file (plan .md filename from plans/), instruction (what to change).
244
+ - **execute-plan**: Execute an existing plan .md file. Fields: file (plan .md filename), project (optional)
245
+ - **file-edit**: Edit any minions file via LLM. Fields: file (path relative to minions dir), instruction (what to change).
246
+
247
+ ## Rules
248
+
249
+ 1. **Use tools proactively.** Read files before answering — don't guess from the state snapshot alone.
250
+ 2. Be specific — cite IDs, agent names, statuses, filenames, line numbers.
251
+ 3. When delegating, include the action block AND explain what you're doing.
252
+ 4. Resolve references like "ripley's plan", "the failing PR" by reading files.
253
+ 5. When recommending which agent to assign, read \`routing.md\` and agent charters.
254
+ 6. Keep responses concise but informative. Use markdown.
255
+ 7. **Never modify engine source code** (engine.js, engine/*.js, dashboard.js/html, minions.js, bin/).
256
+ 8. **Never push to git remotes** without the user explicitly confirming.
257
+ 9. For long-running processes (dev servers), start them detached so they survive after your session.`;
258
+
259
+ function buildCCStatePreamble() {
260
+ // Lightweight snapshot — just enough to orient. Use tools for details.
261
+ const agents = getAgents().map(a => `- ${a.name} (${a.id}): ${a.status}${a.currentTask ? ' — ' + a.currentTask.slice(0, 60) : ''}`).join('\n');
262
+ const projects = PROJECTS.map(p => `- ${p.name}: ${p.localPath}`).join('\n');
263
+
264
+ const dq = getDispatchQueue();
265
+ const active = (dq.active || []).map(d => `- ${d.agentName || d.agent}: ${(d.task || '').slice(0, 50)}`).join('\n') || '(none)';
266
+ const pending = (dq.pending || []).length;
267
+
268
+ const prCount = getPullRequests().length;
269
+ const wiCount = getWorkItems().length;
270
+
271
+ const planFiles = [...safeReadDir(PLANS_DIR), ...safeReadDir(PRD_DIR)].filter(f => f.endsWith('.md') || f.endsWith('.json'));
272
+
273
+ return `### Agents
274
+ ${agents}
275
+
276
+ ### Active Dispatch
277
+ ${active}
278
+ Pending: ${pending}
279
+
280
+ ### Quick Counts
281
+ PRs: ${prCount} | Work items: ${wiCount} | Plans/PRDs on disk: ${planFiles.length}
282
+
283
+ ### Projects
284
+ ${projects}
285
+
286
+ For details on any of the above, use your tools to read files under \`${MINIONS_DIR}\`.`;
287
+ }
288
+
289
+ function parseCCActions(text) {
290
+ let actions = [];
291
+ let displayText = text;
292
+ const delimIdx = text.indexOf('===ACTIONS===');
293
+ if (delimIdx >= 0) {
294
+ displayText = text.slice(0, delimIdx).trim();
295
+ try {
296
+ const parsed = JSON.parse(text.slice(delimIdx + '===ACTIONS==='.length).trim());
297
+ actions = Array.isArray(parsed) ? parsed : [parsed];
298
+ } catch {}
299
+ }
300
+ if (actions.length === 0) {
301
+ const actionRegex = /`{3,}\s*action\s*\r?\n([\s\S]*?)`{3,}/g;
302
+ let match;
303
+ while ((match = actionRegex.exec(displayText)) !== null) {
304
+ try { actions.push(JSON.parse(match[1].trim())); } catch {}
305
+ }
306
+ if (actions.length > 0) displayText = displayText.replace(/`{3,}\s*action\s*\r?\n[\s\S]*?`{3,}\n?/g, '').trim();
307
+ }
308
+ return { text: displayText, actions };
309
+ }
310
+
311
+ // ── Shared LLM call core — used by CC panel and doc modals ──────────────────
312
+
313
+ // Session store for doc modals — keyed by filePath or title, persisted to disk
314
+ const DOC_SESSIONS_PATH = path.join(ENGINE_DIR, 'doc-sessions.json');
315
+ const docSessions = new Map(); // key → { sessionId, lastActiveAt, turnCount }
316
+
317
+ // Load persisted doc sessions on startup
318
+ try {
319
+ const saved = safeJson(DOC_SESSIONS_PATH);
320
+ if (saved && typeof saved === 'object') {
321
+ const now = Date.now();
322
+ for (const [key, s] of Object.entries(saved)) {
323
+ const age = now - new Date(s.lastActiveAt || 0).getTime();
324
+ if (age < CC_SESSION_EXPIRY_MS && s.turnCount < CC_SESSION_MAX_TURNS) {
325
+ docSessions.set(key, s);
326
+ }
327
+ }
328
+ }
329
+ } catch {}
330
+
331
+ function persistDocSessions() {
332
+ const obj = {};
333
+ for (const [key, s] of docSessions) obj[key] = s;
334
+ safeWrite(DOC_SESSIONS_PATH, obj);
335
+ }
336
+
337
+ // Resolve session from any store (CC global or doc-specific)
338
+ function resolveSession(store, key) {
339
+ if (store === 'cc') {
340
+ return ccSessionValid() ? { sessionId: ccSession.sessionId, turnCount: ccSession.turnCount } : null;
341
+ }
342
+ if (!key) return null;
343
+ const s = docSessions.get(key);
344
+ if (!s) return null;
345
+ const age = Date.now() - new Date(s.lastActiveAt).getTime();
346
+ if (age > CC_SESSION_EXPIRY_MS || s.turnCount >= CC_SESSION_MAX_TURNS) {
347
+ docSessions.delete(key);
348
+ persistDocSessions();
349
+ return null;
350
+ }
351
+ return s;
352
+ }
353
+
354
+ // Update session after successful call
355
+ function updateSession(store, key, sessionId, existing) {
356
+ if (!sessionId) return;
357
+ const now = new Date().toISOString();
358
+ if (store === 'cc') {
359
+ ccSession = {
360
+ sessionId,
361
+ createdAt: existing ? ccSession.createdAt : now,
362
+ lastActiveAt: now,
363
+ turnCount: (existing ? ccSession.turnCount : 0) + 1,
364
+ };
365
+ safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
366
+ } else if (key) {
367
+ const prev = docSessions.get(key);
368
+ docSessions.set(key, {
369
+ sessionId,
370
+ lastActiveAt: now,
371
+ turnCount: (existing && prev ? prev.turnCount : 0) + 1,
372
+ });
373
+ persistDocSessions();
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Core LLM call — shared by CC panel and doc modals.
379
+ * @param {string} message - User message
380
+ * @param {object} opts
381
+ * @param {string} opts.store - 'cc' or 'doc'
382
+ * @param {string} opts.sessionKey - Key for doc session (filePath or title)
383
+ * @param {string} opts.extraContext - Additional context prepended to message (e.g., document)
384
+ * @param {string} opts.label - Metrics label
385
+ * @param {number} opts.timeout - Timeout in ms
386
+ * @param {number} opts.maxTurns - Max tool-use turns
387
+ * @param {string} opts.allowedTools - Comma-separated tool list
388
+ */
389
+ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label = 'command-center', timeout = 900000, maxTurns = 25, allowedTools = 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch', skipStatePreamble = false, model = 'sonnet' } = {}) {
390
+ const existing = resolveSession(store, sessionKey);
391
+ let sessionId = existing ? existing.sessionId : null;
392
+
393
+ const parts = skipStatePreamble ? [] : [`## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`];
394
+ if (extraContext) parts.push(extraContext);
395
+ parts.push(message);
396
+ const prompt = parts.join('\n\n---\n\n');
397
+
398
+ let result;
399
+
400
+ // Attempt 1: resume existing session (skip for single-turn/no-tool calls — nothing to resume)
401
+ if (sessionId && maxTurns > 1) {
402
+ result = await llm.callLLM(prompt, '', {
403
+ timeout, label, model, maxTurns, allowedTools, sessionId,
404
+ });
405
+ llm.trackEngineUsage(label, result.usage);
406
+
407
+ if (result.code === 0 && result.text) {
408
+ updateSession(store, sessionKey, result.sessionId || sessionId, true);
409
+ return result;
410
+ }
411
+
412
+ // Distinguish "session exists but call failed" (e.g. tool timeout, signal timeout)
413
+ // from "session is truly dead" (no sessionId returned, or stderr indicates invalid session).
414
+ // If the session still exists, preserve it so the next "try again" can resume.
415
+ const sessionStillValid = llm.isResumeSessionStillValid(result);
416
+ if (sessionStillValid) {
417
+ console.log(`[${label}] Resume call failed (code=${result.code}, empty=${!result.text}) but session is still valid — preserving session for retry`);
418
+ // Update lastActiveAt so session doesn't expire while user retries
419
+ updateSession(store, sessionKey, result.sessionId || sessionId, true);
420
+ return result;
421
+ }
422
+
423
+ console.log(`[${label}] Resume failed — session appears dead (code=${result.code}, empty=${!result.text}), retrying fresh...`);
424
+ sessionId = null;
425
+ // Invalidate the dead session so future calls don't try to resume it
426
+ if (store === 'cc') {
427
+ ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
428
+ safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
429
+ } else if (sessionKey) {
430
+ docSessions.delete(sessionKey);
431
+ persistDocSessions();
432
+ }
433
+ }
434
+
435
+ // Attempt 2: fresh session
436
+ result = await llm.callLLM(prompt, CC_STATIC_SYSTEM_PROMPT, {
437
+ timeout, label, model, maxTurns, allowedTools,
438
+ });
439
+ llm.trackEngineUsage(label, result.usage);
440
+
441
+ if (result.code === 0 && result.text) {
442
+ updateSession(store, sessionKey, result.sessionId, false);
443
+ return result;
444
+ }
445
+
446
+ // Attempt 3: one more retry after a brief pause (skip for single-turn — not worth the latency)
447
+ if (maxTurns <= 1) return result;
448
+ console.log(`[${label}] Fresh call also failed (code=${result.code}, empty=${!result.text}), retrying once more...`);
449
+ await new Promise(r => setTimeout(r, 2000));
450
+ result = await llm.callLLM(prompt, CC_STATIC_SYSTEM_PROMPT, {
451
+ timeout, label, model, maxTurns, allowedTools,
452
+ });
453
+ llm.trackEngineUsage(label, result.usage);
454
+
455
+ if (result.code === 0 && result.text) {
456
+ updateSession(store, sessionKey, result.sessionId, false);
457
+ }
458
+ return result;
459
+ }
460
+
461
+ // Doc-specific wrapper — adds document context, parses ---DOCUMENT---
462
+ async function ccDocCall({ message, document, title, filePath, selection, canEdit, isJson }) {
463
+ const docContext = `## Document Context\n**${title || 'Document'}**${filePath ? ' (`' + filePath + '`)' : ''}${isJson ? ' (JSON)' : ''}\n${selection ? '\n**Selected text:**\n> ' + selection.slice(0, 1500) + '\n' : ''}\n\`\`\`\n${document.slice(0, 20000)}\n\`\`\`\n${canEdit ? '\nIf editing: respond with your explanation, then `---DOCUMENT---` on its own line, then the COMPLETE updated file.' : '\n(Read-only — answer questions only.)'}`;
464
+
465
+ // Plans: Sonnet with tools for codebase-aware Q&A and edits
466
+ // Everything else: Haiku, 1 turn, no tools — fast
467
+ const isPlan = filePath && /^plans\//.test(filePath);
468
+ const result = await ccCall(message, {
469
+ store: 'doc', sessionKey: filePath || title,
470
+ extraContext: docContext, label: 'doc-chat',
471
+ timeout: isPlan ? 300000 : 60000,
472
+ maxTurns: isPlan ? 10 : 1,
473
+ model: isPlan ? 'sonnet' : 'haiku',
474
+ allowedTools: isPlan ? 'Read,Glob,Grep' : '',
475
+ skipStatePreamble: !isPlan,
476
+ });
477
+
478
+ if (result.code !== 0 || !result.text) {
479
+ return { answer: 'Failed to process request. Try again.', content: null, actions: [] };
480
+ }
481
+
482
+ const { text: stripped, actions } = parseCCActions(result.text);
483
+
484
+ const delimIdx = stripped.indexOf('---DOCUMENT---');
485
+ if (delimIdx >= 0) {
486
+ const answer = stripped.slice(0, delimIdx).trim();
487
+ let content = stripped.slice(delimIdx + '---DOCUMENT---'.length).trim();
488
+ content = content.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
489
+ return { answer, content, actions };
490
+ }
491
+
492
+ return { answer: stripped, content: null, actions };
493
+ }
494
+
495
+ // -- POST helpers --
496
+
497
+ function readBody(req) {
498
+ return new Promise((resolve, reject) => {
499
+ let body = '';
500
+ req.on('data', chunk => { body += chunk; if (body.length > 1e6) reject(new Error('Too large')); });
501
+ req.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
502
+ });
503
+ }
504
+
505
+ function jsonReply(res, code, data, req) {
506
+ res.setHeader('Content-Type', 'application/json');
507
+ res.setHeader('Access-Control-Allow-Origin', '*');
508
+ res.statusCode = code;
509
+ const json = JSON.stringify(data);
510
+ const ae = req && req.headers && req.headers['accept-encoding'] || '';
511
+ if (ae.includes('gzip') && json.length > 1024) {
512
+ res.setHeader('Content-Encoding', 'gzip');
513
+ res.end(zlib.gzipSync(json));
514
+ } else {
515
+ res.end(json);
516
+ }
517
+ }
518
+
519
+ // -- Dispatch cleanup helper --
520
+
521
+ /**
522
+ * Remove dispatch entries matching a predicate. Scans pending, active, completed queues.
523
+ * Also kills agent processes for matched active entries.
524
+ * @param {(entry) => boolean} matchFn - return true for entries to remove
525
+ * @returns {number} count of removed entries
526
+ */
527
+ function cleanDispatchEntries(matchFn) {
528
+ const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
529
+ try {
530
+ let removed = 0;
531
+ mutateJsonFileLocked(dispatchPath, (dispatch) => {
532
+ dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
533
+ dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
534
+ dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
535
+ for (const queue of ['pending', 'active', 'completed']) {
536
+ const before = dispatch[queue].length;
537
+ if (queue === 'active') {
538
+ for (const d of dispatch[queue]) {
539
+ if (matchFn(d) && d.agent) {
540
+ const statusPath = path.join(MINIONS_DIR, 'agents', d.agent, 'status.json');
541
+ try {
542
+ const status = JSON.parse(safeRead(statusPath) || '{}');
543
+ if (status.pid) try { process.kill(status.pid, 'SIGTERM'); } catch {}
544
+ status.status = 'idle';
545
+ delete status.currentTask;
546
+ safeWrite(statusPath, status);
547
+ } catch {}
548
+ }
549
+ }
550
+ }
551
+ dispatch[queue] = dispatch[queue].filter(d => !matchFn(d));
552
+ removed += before - dispatch[queue].length;
553
+ }
554
+ return dispatch;
555
+ }, { defaultValue: { pending: [], active: [], completed: [] } });
556
+ return removed;
557
+ } catch { return 0; }
558
+ }
559
+
560
+ // ── Engine Restart Helpers (used by watchdog + API) ─────────────────────────
561
+
562
+ function spawnEngine() {
563
+ const controlPath = path.join(ENGINE_DIR, 'control.json');
564
+ safeWrite(controlPath, { state: 'stopped', pid: null, restarted_at: new Date().toISOString() });
565
+ const { spawn: cpSpawn } = require('child_process');
566
+ const childEnv = { ...process.env };
567
+ for (const key of Object.keys(childEnv)) {
568
+ if (key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE') || key.startsWith('CLAUDECODE_')) delete childEnv[key];
569
+ }
570
+ const engineProc = cpSpawn(process.execPath, [path.join(MINIONS_DIR, 'engine.js'), 'start'], {
571
+ cwd: MINIONS_DIR, stdio: 'ignore', detached: true, env: childEnv,
572
+ });
573
+ engineProc.unref();
574
+ return engineProc.pid;
575
+ }
576
+
577
+ function killEnginePid(pid) {
578
+ const { execSync } = require('child_process');
579
+ try {
580
+ if (process.platform === 'win32') {
581
+ execSync(`taskkill /PID ${pid} /F /T`, { stdio: 'pipe', timeout: 5000 });
582
+ } else {
583
+ process.kill(pid, 'SIGKILL');
584
+ }
585
+ } catch {}
586
+ }
587
+
588
+ function restartEngine() {
589
+ const control = getEngineState();
590
+ if (control.pid) {
591
+ killEnginePid(control.pid);
592
+ console.log(`[watchdog] Killed engine PID ${control.pid}`);
593
+ }
594
+ const newPid = spawnEngine();
595
+ console.log(`[watchdog] Engine restarted (new PID: ${newPid})`);
596
+ return newPid;
597
+ }
598
+
599
+ // -- Server --
600
+
601
+ const server = http.createServer(async (req, res) => {
602
+ // CORS preflight
603
+ if (req.method === 'OPTIONS') {
604
+ res.setHeader('Access-Control-Allow-Origin', '*');
605
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
606
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
607
+ res.statusCode = 204;
608
+ res.end();
609
+ return;
610
+ }
611
+
612
+ // POST /api/plans/trigger-verify — manually trigger verification for a completed plan
613
+ if (req.method === 'POST' && req.url === '/api/plans/trigger-verify') {
614
+ try {
615
+ const body = await readBody(req);
616
+ if (!body.file) return jsonReply(res, 400, { error: 'file required' });
617
+
618
+ // Find the PRD — check active and archive
619
+ const prdDir = path.join(MINIONS_DIR, 'prd');
620
+ let prdPath = path.join(prdDir, body.file);
621
+ let fromArchive = false;
622
+ if (!fs.existsSync(prdPath)) {
623
+ prdPath = path.join(prdDir, 'archive', body.file);
624
+ fromArchive = true;
625
+ }
626
+ if (!fs.existsSync(prdPath)) return jsonReply(res, 404, { error: 'PRD not found' });
627
+
628
+ // If archived, temporarily restore to active so checkPlanCompletion can find it
629
+ const activePath = path.join(prdDir, body.file);
630
+ if (fromArchive) {
631
+ const plan = JSON.parse(safeRead(prdPath));
632
+ plan.status = 'approved';
633
+ delete plan.completedAt;
634
+ safeWrite(activePath, plan);
635
+ }
636
+
637
+ // Trigger completion check
638
+ const lifecycle = require('./engine/lifecycle');
639
+ const config = queries.getConfig();
640
+ lifecycle.checkPlanCompletion({ item: { sourcePlan: body.file, id: 'manual' } }, config);
641
+
642
+ // Check if verify was created
643
+ const project = PROJECTS.find(p => {
644
+ const plan = JSON.parse(safeRead(activePath) || safeRead(prdPath) || '{}');
645
+ return p.name?.toLowerCase() === (plan.project || '').toLowerCase();
646
+ }) || PROJECTS[0];
647
+ if (project) {
648
+ const wiPath = shared.projectWorkItemsPath(project);
649
+ const items = JSON.parse(safeRead(wiPath) || '[]');
650
+ const verify = items.find(w => w.sourcePlan === body.file && w.itemType === 'verify');
651
+ if (verify) {
652
+ return jsonReply(res, 200, { ok: true, verifyId: verify.id });
653
+ }
654
+ }
655
+ return jsonReply(res, 200, { ok: true, message: 'Completion check ran but no verify task was needed' });
656
+ } catch (e) { return jsonReply(res, 500, { error: e.message }); }
657
+ }
658
+
659
+ // POST /api/work-items/retry — reset a failed/dispatched item to pending
660
+ if (req.method === 'POST' && req.url === '/api/work-items/retry') {
661
+ try {
662
+ const body = await readBody(req);
663
+ const { id, source } = body;
664
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
665
+
666
+ // Find the right file
667
+ let wiPath;
668
+ if (!source || source === 'central') {
669
+ wiPath = path.join(MINIONS_DIR, 'work-items.json');
670
+ } else {
671
+ const proj = PROJECTS.find(p => p.name === source);
672
+ if (proj) {
673
+ wiPath = shared.projectWorkItemsPath(proj);
674
+ }
675
+ }
676
+ if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
677
+
678
+ const items = JSON.parse(safeRead(wiPath) || '[]');
679
+ const item = items.find(i => i.id === id);
680
+ if (!item) return jsonReply(res, 404, { error: 'item not found' });
681
+
682
+ item.status = 'pending';
683
+ item._retryCount = 0; // Reset retry counter on manual retry
684
+ delete item.dispatched_at;
685
+ delete item.dispatched_to;
686
+ delete item.failReason;
687
+ delete item.failedAt;
688
+ delete item.fanOutAgents;
689
+ safeWrite(wiPath, items);
690
+
691
+ // Clear completed dispatch entries so the engine doesn't dedup this item
692
+ const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
693
+ const sourcePrefix = (!source || source === 'central') ? 'central-work-' : `work-${source}-`;
694
+ const dispatchKey = sourcePrefix + id;
695
+ try {
696
+ mutateJsonFileLocked(dispatchPath, (dispatch) => {
697
+ dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
698
+ dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
699
+ dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
700
+ return dispatch;
701
+ }, { defaultValue: { pending: [], active: [], completed: [] } });
702
+ } catch {}
703
+
704
+ // Clear cooldown so item isn't blocked by exponential backoff
705
+ try {
706
+ const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
707
+ const cooldowns = JSON.parse(safeRead(cooldownPath) || '{}');
708
+ if (cooldowns[dispatchKey]) {
709
+ delete cooldowns[dispatchKey];
710
+ safeWrite(cooldownPath, cooldowns);
711
+ }
712
+ } catch {}
713
+
714
+ return jsonReply(res, 200, { ok: true, id });
715
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
716
+ }
717
+
718
+ // POST /api/work-items/delete — remove a work item, kill agent, clear dispatch
719
+ if (req.method === 'POST' && req.url === '/api/work-items/delete') {
720
+ try {
721
+ const body = await readBody(req);
722
+ const { id, source } = body;
723
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
724
+
725
+ // Find the right work-items file
726
+ let wiPath;
727
+ if (!source || source === 'central') {
728
+ wiPath = path.join(MINIONS_DIR, 'work-items.json');
729
+ } else {
730
+ const proj = PROJECTS.find(p => p.name === source);
731
+ if (proj) {
732
+ wiPath = shared.projectWorkItemsPath(proj);
733
+ }
734
+ }
735
+ if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
736
+
737
+ const items = JSON.parse(safeRead(wiPath) || '[]');
738
+ const idx = items.findIndex(i => i.id === id);
739
+ if (idx === -1) return jsonReply(res, 404, { error: 'item not found' });
740
+
741
+ const item = items[idx];
742
+
743
+ // Remove item from work-items file
744
+ items.splice(idx, 1);
745
+ safeWrite(wiPath, items);
746
+
747
+ // Clean dispatch entries + kill running agent
748
+ const sourcePrefix = (!source || source === 'central') ? 'central-work-' : `work-${source}-`;
749
+ const dispatchKey = sourcePrefix + id;
750
+ cleanDispatchEntries(d =>
751
+ d.meta?.dispatchKey === dispatchKey ||
752
+ (d.meta?.parentKey && d.meta.parentKey === dispatchKey) ||
753
+ d.meta?.item?.id === id
754
+ );
755
+
756
+ return jsonReply(res, 200, { ok: true, id });
757
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
758
+ }
759
+
760
+ // POST /api/work-items/archive — move a completed/failed work item to archive
761
+ if (req.method === 'POST' && req.url === '/api/work-items/archive') {
762
+ try {
763
+ const body = await readBody(req);
764
+ const { id, source } = body;
765
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
766
+
767
+ let wiPath;
768
+ if (!source || source === 'central') {
769
+ wiPath = path.join(MINIONS_DIR, 'work-items.json');
770
+ } else {
771
+ const proj = PROJECTS.find(p => p.name === source);
772
+ if (proj) {
773
+ wiPath = shared.projectWorkItemsPath(proj);
774
+ }
775
+ }
776
+ if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
777
+
778
+ const items = JSON.parse(safeRead(wiPath) || '[]');
779
+ const idx = items.findIndex(i => i.id === id);
780
+ if (idx === -1) return jsonReply(res, 404, { error: 'item not found' });
781
+
782
+ const item = items.splice(idx, 1)[0];
783
+ item.archivedAt = new Date().toISOString();
784
+
785
+ // Append to archive file
786
+ const archivePath = wiPath.replace('.json', '-archive.json');
787
+ let archive = [];
788
+ const existing = safeRead(archivePath);
789
+ if (existing) { try { archive = JSON.parse(existing); } catch {} }
790
+ archive.push(item);
791
+ safeWrite(archivePath, archive);
792
+ safeWrite(wiPath, items);
793
+
794
+ // Clean dispatch entries for archived item
795
+ const sourcePrefix = (!source || source === 'central') ? 'central-work-' : `work-${source}-`;
796
+ cleanDispatchEntries(d =>
797
+ d.meta?.dispatchKey === sourcePrefix + id ||
798
+ d.meta?.item?.id === id
799
+ );
800
+
801
+ return jsonReply(res, 200, { ok: true, id });
802
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
803
+ }
804
+
805
+ // GET /api/work-items/archive — list archived work items
806
+ if (req.method === 'GET' && req.url === '/api/work-items/archive') {
807
+ try {
808
+ let allArchived = [];
809
+ // Central archive
810
+ const centralPath = path.join(MINIONS_DIR, 'work-items-archive.json');
811
+ const central = safeRead(centralPath);
812
+ if (central) { try { allArchived.push(...JSON.parse(central).map(i => ({ ...i, _source: 'central' }))); } catch {} }
813
+ // Project archives
814
+ for (const project of PROJECTS) {
815
+ const archPath = shared.projectWorkItemsPath(project).replace('.json', '-archive.json');
816
+ const content = safeRead(archPath);
817
+ if (content) { try { allArchived.push(...JSON.parse(content).map(i => ({ ...i, _source: project.name }))); } catch {} }
818
+ }
819
+ return jsonReply(res, 200, allArchived);
820
+ } catch (e) { console.error('Archive fetch error:', e.message); return jsonReply(res, 500, { error: e.message }); }
821
+ }
822
+
823
+ // POST /api/work-items
824
+ if (req.method === 'POST' && req.url === '/api/work-items') {
825
+ try {
826
+ const body = await readBody(req);
827
+ if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
828
+ let wiPath;
829
+ if (body.project) {
830
+ // Write to project-specific queue
831
+ const targetProject = PROJECTS.find(p => p.name === body.project) || PROJECTS[0];
832
+ wiPath = shared.projectWorkItemsPath(targetProject);
833
+ } else {
834
+ // Write to central queue — agent decides which project
835
+ wiPath = path.join(MINIONS_DIR, 'work-items.json');
836
+ }
837
+ let items = [];
838
+ const existing = safeRead(wiPath);
839
+ if (existing) { try { items = JSON.parse(existing); } catch {} }
840
+ const id = 'W-' + shared.uid();
841
+ const item = {
842
+ id, title: body.title, type: body.type || 'implement',
843
+ priority: body.priority || 'medium', description: body.description || '',
844
+ status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard',
845
+ };
846
+ if (body.scope) item.scope = body.scope;
847
+ if (body.agent) item.agent = body.agent;
848
+ if (body.agents) item.agents = body.agents;
849
+ items.push(item);
850
+ safeWrite(wiPath, items);
851
+ return jsonReply(res, 200, { ok: true, id });
852
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
853
+ }
854
+
855
+ // POST /api/notes — write to inbox so it flows through normal consolidation
856
+ if (req.method === 'POST' && req.url === '/api/notes') {
857
+ try {
858
+ const body = await readBody(req);
859
+ if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
860
+ const inboxDir = path.join(MINIONS_DIR, 'notes', 'inbox');
861
+ fs.mkdirSync(inboxDir, { recursive: true });
862
+ const today = new Date().toISOString().slice(0, 10);
863
+ const author = body.author || os.userInfo().username;
864
+ const slug = (body.title || 'note').toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40);
865
+ const filename = `${author}-${slug}-${today}-${shared.uid().slice(-4)}.md`;
866
+ const content = `# ${body.title}\n\n**By:** ${author}\n**Date:** ${today}\n\n${body.what}\n${body.why ? '\n**Why:** ' + body.why + '\n' : ''}`;
867
+ safeWrite(shared.uniquePath(path.join(inboxDir, filename)), content);
868
+ return jsonReply(res, 200, { ok: true });
869
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
870
+ }
871
+
872
+ // POST /api/plan — create a plan work item that chains to PRD on completion
873
+ if (req.method === 'POST' && req.url === '/api/plan') {
874
+ try {
875
+ const body = await readBody(req);
876
+ if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
877
+ // Write as a work item with type 'plan' — user must explicitly execute plan-to-prd after reviewing
878
+ const wiPath = path.join(MINIONS_DIR, 'work-items.json');
879
+ let items = [];
880
+ const existing = safeRead(wiPath);
881
+ if (existing) { try { items = JSON.parse(existing); } catch {} }
882
+ const id = 'W-' + shared.uid();
883
+ const item = {
884
+ id, title: body.title, type: 'plan',
885
+ priority: body.priority || 'high', description: body.description || '',
886
+ status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard',
887
+ branchStrategy: body.branch_strategy || 'parallel',
888
+ };
889
+ if (body.project) item.project = body.project;
890
+ if (body.agent) item.agent = body.agent;
891
+ items.push(item);
892
+ safeWrite(wiPath, items);
893
+ return jsonReply(res, 200, { ok: true, id, agent: body.agent || '' });
894
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
895
+ }
896
+
897
+ // POST /api/prd-items — create a PRD item as a plan file in prd/ (auto-approved)
898
+ if (req.method === 'POST' && req.url === '/api/prd-items') {
899
+ try {
900
+ const body = await readBody(req);
901
+ if (!body.name || !body.name.trim()) return jsonReply(res, 400, { error: 'name is required' });
902
+
903
+ if (!fs.existsSync(PRD_DIR)) fs.mkdirSync(PRD_DIR, { recursive: true });
904
+
905
+ const id = body.id || ('M' + String(Date.now()).slice(-4));
906
+ const planFile = 'manual-' + shared.uid() + '.json';
907
+ const plan = {
908
+ version: 'manual-' + new Date().toISOString().slice(0, 10),
909
+ project: body.project || (PROJECTS[0]?.name || 'Unknown'),
910
+ generated_by: 'dashboard',
911
+ generated_at: new Date().toISOString().slice(0, 10),
912
+ plan_summary: body.name,
913
+ status: 'approved',
914
+ requires_approval: false,
915
+ branch_strategy: 'parallel',
916
+ missing_features: [{
917
+ id, name: body.name, description: body.description || '',
918
+ priority: body.priority || 'medium', estimated_complexity: body.estimated_complexity || 'medium',
919
+ status: 'missing', depends_on: [], acceptance_criteria: [],
920
+ }],
921
+ open_questions: [],
922
+ };
923
+ safeWrite(path.join(PRD_DIR, planFile), plan);
924
+ return jsonReply(res, 200, { ok: true, id, file: planFile });
925
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
926
+ }
927
+
928
+ // POST /api/prd-items/update — edit a PRD item in its source plan JSON
929
+ if (req.method === 'POST' && req.url === '/api/prd-items/update') {
930
+ try {
931
+ const body = await readBody(req);
932
+ if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
933
+ const planPath = resolvePlanPath(body.source);
934
+ if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
935
+ const plan = safeJson(planPath);
936
+ const item = (plan.missing_features || []).find(f => f.id === body.itemId);
937
+ if (!item) return jsonReply(res, 404, { error: 'item not found in plan' });
938
+
939
+ // Update allowed fields
940
+ if (body.name !== undefined) item.name = body.name;
941
+ if (body.description !== undefined) item.description = body.description;
942
+ if (body.priority !== undefined) item.priority = body.priority;
943
+ if (body.estimated_complexity !== undefined) item.estimated_complexity = body.estimated_complexity;
944
+ if (body.status !== undefined) item.status = body.status;
945
+
946
+ // Re-read plan before writing to minimize race window with engine
947
+ const freshPlan = safeJson(planPath) || plan;
948
+ const freshItem = (freshPlan.missing_features || []).find(f => f.id === body.itemId);
949
+ if (freshItem) {
950
+ if (body.name !== undefined) freshItem.name = body.name;
951
+ if (body.description !== undefined) freshItem.description = body.description;
952
+ if (body.priority !== undefined) freshItem.priority = body.priority;
953
+ if (body.estimated_complexity !== undefined) freshItem.estimated_complexity = body.estimated_complexity;
954
+ if (body.status !== undefined) freshItem.status = body.status;
955
+ }
956
+ safeWrite(planPath, freshPlan);
957
+
958
+ // Feature 3: Sync edits to materialized work item if still pending
959
+ let workItemSynced = false;
960
+ const wiSyncPaths = [path.join(MINIONS_DIR, 'work-items.json')];
961
+ for (const proj of PROJECTS) {
962
+ wiSyncPaths.push(shared.projectWorkItemsPath(proj));
963
+ }
964
+ for (const wiPath of wiSyncPaths) {
965
+ try {
966
+ const items = safeJson(wiPath);
967
+ const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
968
+ if (wi && wi.status === 'pending') {
969
+ if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
970
+ if (body.description !== undefined) wi.description = body.description;
971
+ if (body.priority !== undefined) wi.priority = body.priority;
972
+ if (body.estimated_complexity !== undefined) {
973
+ wi.type = body.estimated_complexity === 'large' ? 'implement:large' : 'implement';
974
+ }
975
+ safeWrite(wiPath, items);
976
+ workItemSynced = true;
977
+ }
978
+ } catch {}
979
+ }
980
+
981
+ return jsonReply(res, 200, { ok: true, item, workItemSynced });
982
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
983
+ }
984
+
985
+ // POST /api/prd-items/remove — remove a PRD item from plan + cancel materialized work item
986
+ if (req.method === 'POST' && req.url === '/api/prd-items/remove') {
987
+ try {
988
+ const body = await readBody(req);
989
+ if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
990
+ const planPath = resolvePlanPath(body.source);
991
+ if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
992
+ const plan = safeJson(planPath);
993
+ const idx = (plan.missing_features || []).findIndex(f => f.id === body.itemId);
994
+ if (idx < 0) return jsonReply(res, 404, { error: 'item not found in plan' });
995
+
996
+ plan.missing_features.splice(idx, 1);
997
+ safeWrite(planPath, plan);
998
+
999
+ // Also remove any materialized work item for this plan item
1000
+ let cancelled = false;
1001
+ for (const proj of PROJECTS) {
1002
+ const wiPath = shared.projectWorkItemsPath(proj);
1003
+ try {
1004
+ const items = safeJson(wiPath);
1005
+ const before = items.length;
1006
+ const filtered = items.filter(w => !(w.sourcePlan === body.source && w.id === body.itemId));
1007
+ if (filtered.length < before) {
1008
+ safeWrite(wiPath, filtered);
1009
+ cancelled = true;
1010
+ }
1011
+ } catch {}
1012
+ }
1013
+ // Also check central work-items
1014
+ const centralPath = path.join(MINIONS_DIR, 'work-items.json');
1015
+ try {
1016
+ const items = safeJson(centralPath);
1017
+ const before = items.length;
1018
+ const filtered = items.filter(w => !(w.sourcePlan === body.source && w.id === body.itemId));
1019
+ if (filtered.length < before) { safeWrite(centralPath, filtered); cancelled = true; }
1020
+ } catch {}
1021
+
1022
+ // Clean dispatch entries for this item
1023
+ cleanDispatchEntries(d =>
1024
+ d.meta?.item?.sourcePlan === body.source && d.meta?.item?.id === body.itemId
1025
+ );
1026
+
1027
+ return jsonReply(res, 200, { ok: true, cancelled });
1028
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1029
+ }
1030
+
1031
+ // POST /api/agents/cancel — cancel an active agent by ID or task substring
1032
+ if (req.method === 'POST' && req.url === '/api/agents/cancel') {
1033
+ try {
1034
+ const body = await readBody(req);
1035
+ const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
1036
+ const dispatch = JSON.parse(safeRead(dispatchPath) || '{}');
1037
+ const active = dispatch.active || [];
1038
+ const cancelled = [];
1039
+
1040
+ for (const d of active) {
1041
+ const matchAgent = body.agent && d.agent === body.agent;
1042
+ const matchTask = body.task && (d.task || '').toLowerCase().includes((body.task || '').toLowerCase());
1043
+ if (!matchAgent && !matchTask) continue;
1044
+
1045
+ // Kill agent process
1046
+ const statusPath = path.join(MINIONS_DIR, 'agents', d.agent, 'status.json');
1047
+ try {
1048
+ const status = JSON.parse(safeRead(statusPath) || '{}');
1049
+ if (status.pid) {
1050
+ if (process.platform === 'win32') {
1051
+ try { require('child_process').execSync('taskkill /PID ' + status.pid + ' /F /T', { stdio: 'pipe', timeout: 5000 }); } catch {}
1052
+ } else {
1053
+ try { process.kill(status.pid, 'SIGTERM'); } catch {}
1054
+ }
1055
+ }
1056
+ status.status = 'idle';
1057
+ delete status.currentTask;
1058
+ delete status.dispatched;
1059
+ safeWrite(statusPath, status);
1060
+ } catch {}
1061
+
1062
+ cancelled.push({ agent: d.agent, task: d.task });
1063
+ }
1064
+
1065
+ // Remove cancelled from active dispatch
1066
+ if (cancelled.length > 0) {
1067
+ const cancelledIds = new Set(cancelled.map(c => c.agent));
1068
+ mutateJsonFileLocked(dispatchPath, (dp) => {
1069
+ dp.active = Array.isArray(dp.active) ? dp.active : [];
1070
+ dp.active = dp.active.filter(d => !cancelledIds.has(d.agent));
1071
+ return dp;
1072
+ }, { defaultValue: { pending: [], active: [], completed: [] } });
1073
+ }
1074
+
1075
+ return jsonReply(res, 200, { ok: true, cancelled });
1076
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1077
+ }
1078
+
1079
+ // GET /api/agent/:id/live — tail live output for a working agent
1080
+ const liveMatch = req.url.match(/^\/api\/agent\/([\w-]+)\/live(?:\?.*)?$/);
1081
+ if (liveMatch && req.method === 'GET') {
1082
+ const agentId = liveMatch[1];
1083
+ const livePath = path.join(MINIONS_DIR, 'agents', agentId, 'live-output.log');
1084
+ const content = safeRead(livePath);
1085
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
1086
+ res.setHeader('Access-Control-Allow-Origin', '*');
1087
+ if (!content) {
1088
+ res.end('No live output. Agent may not be running.');
1089
+ } else {
1090
+ // Return last N bytes via ?tail=N param (default last 8KB)
1091
+ const params = new URL(req.url, 'http://localhost').searchParams;
1092
+ const tailBytes = parseInt(params.get('tail')) || 8192;
1093
+ res.end(content.length > tailBytes ? content.slice(-tailBytes) : content);
1094
+ }
1095
+ return;
1096
+ }
1097
+
1098
+ // GET /api/agent/:id/output — fetch final output.log for an agent
1099
+ const outputMatch = req.url.match(/^\/api\/agent\/([\w-]+)\/output(?:\?.*)?$/);
1100
+ if (outputMatch && req.method === 'GET') {
1101
+ const agentId = outputMatch[1];
1102
+ const outputPath = path.join(MINIONS_DIR, 'agents', agentId, 'output.log');
1103
+ const content = safeRead(outputPath);
1104
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
1105
+ res.setHeader('Access-Control-Allow-Origin', '*');
1106
+ res.end(content || 'No output log found for this agent.');
1107
+ return;
1108
+ }
1109
+
1110
+ // GET /api/notes-full — return full notes.md content
1111
+ if (req.method === 'GET' && req.url === '/api/notes-full') {
1112
+ const content = safeRead(path.join(MINIONS_DIR, 'notes.md'));
1113
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
1114
+ res.setHeader('Access-Control-Allow-Origin', '*');
1115
+ res.end(content || 'No notes file found.');
1116
+ return;
1117
+ }
1118
+
1119
+ // POST /api/notes-save — save edited notes.md content
1120
+ if (req.method === 'POST' && req.url === '/api/notes-save') {
1121
+ try {
1122
+ const body = await readBody(req);
1123
+ if (!body.content && body.content !== '') return jsonReply(res, 400, { error: 'content required' });
1124
+ const file = body.file || 'notes.md';
1125
+ // Only allow saving notes.md (prevent arbitrary file writes)
1126
+ if (file !== 'notes.md') return jsonReply(res, 400, { error: 'only notes.md can be edited' });
1127
+ safeWrite(path.join(MINIONS_DIR, file), body.content);
1128
+ return jsonReply(res, 200, { ok: true });
1129
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1130
+ }
1131
+
1132
+ // GET /api/knowledge — list all knowledge base entries grouped by category
1133
+ if (req.method === 'GET' && req.url === '/api/knowledge') {
1134
+ const entries = getKnowledgeBaseEntries();
1135
+ const result = {};
1136
+ for (const cat of shared.KB_CATEGORIES) result[cat] = [];
1137
+ for (const e of entries) {
1138
+ if (!result[e.cat]) result[e.cat] = [];
1139
+ result[e.cat].push({ file: e.file, category: e.cat, title: e.title, agent: e.agent, date: e.date, size: e.size, preview: e.preview });
1140
+ }
1141
+ const swept = safeJson(path.join(ENGINE_DIR, 'kb-swept.json'));
1142
+ if (swept) result.lastSwept = swept.timestamp;
1143
+ return jsonReply(res, 200, result);
1144
+ }
1145
+
1146
+ // GET /api/knowledge/:category/:file — read a specific knowledge base entry
1147
+ const kbMatch = req.url.match(/^\/api\/knowledge\/([^/]+)\/([^?]+)/);
1148
+ if (kbMatch && req.method === 'GET') {
1149
+ const cat = kbMatch[1];
1150
+ const file = decodeURIComponent(kbMatch[2]);
1151
+ // Prevent path traversal
1152
+ if (file.includes('..') || file.includes('/') || file.includes('\\')) {
1153
+ return jsonReply(res, 400, { error: 'invalid file name' });
1154
+ }
1155
+ const content = safeRead(path.join(MINIONS_DIR, 'knowledge', cat, file));
1156
+ if (content === null) return jsonReply(res, 404, { error: 'not found' });
1157
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
1158
+ res.setHeader('Access-Control-Allow-Origin', '*');
1159
+ res.end(content);
1160
+ return;
1161
+ }
1162
+
1163
+ // POST /api/knowledge/sweep — deduplicate, consolidate, and reorganize knowledge base
1164
+ if (req.method === 'POST' && req.url === '/api/knowledge/sweep') {
1165
+ if (global._kbSweepInFlight) return jsonReply(res, 409, { error: 'sweep already in progress' });
1166
+ global._kbSweepInFlight = true;
1167
+ try {
1168
+ const entries = getKnowledgeBaseEntries();
1169
+ if (entries.length < 2) return jsonReply(res, 200, { ok: true, summary: 'nothing to sweep (< 2 entries)' });
1170
+
1171
+ // Build a manifest of all KB entries with their content
1172
+ const manifest = [];
1173
+ for (const e of entries) {
1174
+ const content = safeRead(path.join(MINIONS_DIR, 'knowledge', e.cat, e.file));
1175
+ if (!content) continue;
1176
+ manifest.push({ category: e.cat, file: e.file, title: e.title, agent: e.agent, date: e.date, content: content.slice(0, 3000) });
1177
+ }
1178
+
1179
+ const prompt = `You are a knowledge base curator. Analyze these ${manifest.length} knowledge base entries and produce a cleanup plan.
1180
+
1181
+ ## Entries
1182
+
1183
+ ${manifest.map((m, i) => `<entry index="${i}" category="${m.category}" file="${m.file}" date="${m.date}" agent="${m.agent || 'unknown'}">
1184
+ ${m.title}
1185
+ ${m.content.slice(0, 1500)}
1186
+ </entry>`).join('\n\n')}
1187
+
1188
+ ## Instructions
1189
+
1190
+ 1. **Find duplicates**: entries with substantially the same content or insights (same findings from different agents or dispatch runs). List pairs/groups by index. When choosing which to keep, prefer the more recent entry (later date) as it likely reflects the current state of the codebase.
1191
+
1192
+ 2. **Find misclassified**: entries in the wrong category. Common: build reports in conventions, reviews in architecture.
1193
+
1194
+ 3. **Find stale/empty**: entries with no actionable content (boilerplate, "no changes needed", bail-out notes).
1195
+
1196
+ ## Output Format
1197
+
1198
+ Respond with ONLY valid JSON (no markdown fences, no preamble):
1199
+
1200
+ {
1201
+ "duplicates": [
1202
+ { "keep": 0, "remove": [1, 5], "reason": "same PR review findings" }
1203
+ ],
1204
+ "reclassify": [
1205
+ { "index": 3, "from": "conventions", "to": "build-reports", "reason": "..." }
1206
+ ],
1207
+ "remove": [
1208
+ { "index": 7, "reason": "empty bail-out note" }
1209
+ ]
1210
+ }
1211
+
1212
+ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1213
+
1214
+ const { callLLM, trackEngineUsage } = require('./engine/llm');
1215
+ const result = await callLLM(prompt, 'You are a concise knowledge curator. Output only JSON.', {
1216
+ timeout: 180000, label: 'kb-sweep', model: 'haiku', maxTurns: 1
1217
+ });
1218
+ trackEngineUsage('kb-sweep', result.usage);
1219
+
1220
+ let plan;
1221
+ try {
1222
+ let jsonStr = result.text.trim();
1223
+ const fenceMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
1224
+ if (fenceMatch) jsonStr = fenceMatch[1].trim();
1225
+ plan = JSON.parse(jsonStr);
1226
+ } catch {
1227
+ return jsonReply(res, 200, { ok: false, error: 'LLM returned invalid JSON', raw: result.text.slice(0, 500) });
1228
+ }
1229
+
1230
+ let removed = 0, reclassified = 0, merged = 0;
1231
+ const kbDir = path.join(MINIONS_DIR, 'knowledge');
1232
+
1233
+ // If nothing to do, return early
1234
+ const totalActions = (plan.remove || []).length + (plan.duplicates || []).reduce((n, d) => n + (d.remove || []).length, 0) + (plan.reclassify || []).length;
1235
+ if (totalActions === 0) {
1236
+ return jsonReply(res, 200, { ok: true, summary: 'KB is clean — nothing to sweep', plan });
1237
+ }
1238
+
1239
+ // Archive dir for swept files (never delete, always preserve)
1240
+ const kbArchiveDir = path.join(kbDir, '_swept');
1241
+ if (!fs.existsSync(kbArchiveDir)) fs.mkdirSync(kbArchiveDir, { recursive: true });
1242
+
1243
+ function archiveKbFile(filePath, reason) {
1244
+ if (!fs.existsSync(filePath)) return;
1245
+ const basename = path.basename(filePath);
1246
+ const destPath = shared.uniquePath(path.join(kbArchiveDir, basename));
1247
+ try {
1248
+ const content = safeRead(filePath);
1249
+ if (content === null) return; // don't delete if we can't read
1250
+ const meta = `<!-- swept: ${new Date().toISOString()} | reason: ${reason} -->\n`;
1251
+ safeWrite(destPath, meta + content);
1252
+ safeUnlink(filePath);
1253
+ } catch {}
1254
+ }
1255
+
1256
+ // Process removals (stale/empty) — archive, not delete
1257
+ for (const r of (plan.remove || [])) {
1258
+ const entry = manifest[r.index];
1259
+ if (!entry) continue;
1260
+ const fp = path.join(kbDir, entry.category, entry.file);
1261
+ archiveKbFile(fp, 'stale: ' + (r.reason || ''));
1262
+ removed++;
1263
+ }
1264
+
1265
+ // Process duplicates — archive the duplicates, keep the primary
1266
+ for (const d of (plan.duplicates || [])) {
1267
+ for (const idx of (d.remove || [])) {
1268
+ const entry = manifest[idx];
1269
+ if (!entry) continue;
1270
+ const fp = path.join(kbDir, entry.category, entry.file);
1271
+ archiveKbFile(fp, 'duplicate of index ' + d.keep + ': ' + (d.reason || ''));
1272
+ merged++;
1273
+ }
1274
+ }
1275
+
1276
+ // Process reclassifications (move between categories)
1277
+ for (const r of (plan.reclassify || [])) {
1278
+ const entry = manifest[r.index];
1279
+ if (!entry || !shared.KB_CATEGORIES.includes(r.to)) continue;
1280
+ const srcPath = path.join(kbDir, entry.category, entry.file);
1281
+ const destDir = path.join(kbDir, r.to);
1282
+ if (!fs.existsSync(srcPath)) continue;
1283
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
1284
+ try {
1285
+ const content = safeRead(srcPath);
1286
+ const updated = content.replace(/^(category:\s*).+$/m, `$1${r.to}`);
1287
+ safeWrite(path.join(destDir, entry.file), updated);
1288
+ safeUnlink(srcPath);
1289
+ reclassified++;
1290
+ } catch {}
1291
+ }
1292
+
1293
+ // Prune swept files older than 30 days
1294
+ let pruned = 0;
1295
+ const SWEPT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
1296
+ try {
1297
+ for (const f of fs.readdirSync(kbArchiveDir)) {
1298
+ const fp = path.join(kbArchiveDir, f);
1299
+ try {
1300
+ if (Date.now() - fs.statSync(fp).mtimeMs > SWEPT_RETENTION_MS) { safeUnlink(fp); pruned++; }
1301
+ } catch {}
1302
+ }
1303
+ } catch {}
1304
+
1305
+ const summary = `${merged} duplicates merged, ${removed} stale removed, ${reclassified} reclassified${pruned ? ', ' + pruned + ' old swept files pruned' : ''}`;
1306
+ safeWrite(path.join(ENGINE_DIR, 'kb-swept.json'), JSON.stringify({ timestamp: new Date().toISOString(), summary }));
1307
+ return jsonReply(res, 200, { ok: true, summary, plan });
1308
+ } catch (e) { return jsonReply(res, 500, { error: e.message }); } finally { global._kbSweepInFlight = false; }
1309
+ }
1310
+
1311
+ // GET /api/plans — list plan files (.md drafts from plans/ + .json PRDs from prd/)
1312
+ if (req.method === 'GET' && req.url === '/api/plans') {
1313
+ const dirs = [
1314
+ { dir: PLANS_DIR, archived: false },
1315
+ { dir: path.join(PLANS_DIR, 'archive'), archived: true },
1316
+ { dir: PRD_DIR, archived: false },
1317
+ { dir: path.join(PRD_DIR, 'archive'), archived: true },
1318
+ ];
1319
+ // Load work items to check for completed plan-to-prd conversions
1320
+ const centralWi = JSON.parse(safeRead(path.join(MINIONS_DIR, 'work-items.json')) || '[]');
1321
+ const completedPrdFiles = new Set(
1322
+ centralWi.filter(w => w.type === 'plan-to-prd' && w.status === 'done' && w.planFile)
1323
+ .map(w => w.planFile)
1324
+ );
1325
+ const plans = [];
1326
+ for (const { dir, archived } of dirs) {
1327
+ const allFiles = safeReadDir(dir).filter(f => f.endsWith('.json') || f.endsWith('.md'));
1328
+ for (const f of allFiles) {
1329
+ const filePath = path.join(dir, f);
1330
+ const content = safeRead(filePath) || '';
1331
+ let updatedAt = '';
1332
+ try { updatedAt = new Date(fs.statSync(filePath).mtimeMs).toISOString(); } catch {}
1333
+ const isJson = f.endsWith('.json');
1334
+ if (isJson) {
1335
+ try {
1336
+ const plan = JSON.parse(content);
1337
+ const status = plan.status || 'active';
1338
+ plans.push({
1339
+ file: f, format: 'prd', archived,
1340
+ project: plan.project || '',
1341
+ summary: plan.plan_summary || '',
1342
+ status,
1343
+ branchStrategy: plan.branch_strategy || 'parallel',
1344
+ featureBranch: plan.feature_branch || '',
1345
+ itemCount: (plan.missing_features || []).length,
1346
+ generatedBy: plan.generated_by || '',
1347
+ generatedAt: plan.generated_at || '',
1348
+ completedAt: plan.completedAt || '',
1349
+ updatedAt,
1350
+ requiresApproval: plan.requires_approval || false,
1351
+ revisionFeedback: plan.revision_feedback || null,
1352
+ sourcePlan: plan.source_plan || null,
1353
+ });
1354
+ } catch {}
1355
+ } else {
1356
+ const titleMatch = content.match(/^#\s+(?:Plan:\s*)?(.+)/m);
1357
+ const projectMatch = content.match(/\*\*Project:\*\*\s*(.+)/m);
1358
+ const authorMatch = content.match(/\*\*Author:\*\*\s*(.+)/m);
1359
+ const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/m);
1360
+ const versionMatch = f.match(/-v(\d+)/);
1361
+ plans.push({
1362
+ file: f, format: 'draft', archived,
1363
+ project: projectMatch ? projectMatch[1].trim() : '',
1364
+ summary: titleMatch ? titleMatch[1].trim() : f.replace('.md', ''),
1365
+ status: archived ? 'completed' : completedPrdFiles.has(f) ? 'converted' : 'draft',
1366
+ branchStrategy: '',
1367
+ featureBranch: '',
1368
+ itemCount: (content.match(/^\d+\.\s+\*\*/gm) || []).length,
1369
+ generatedBy: authorMatch ? authorMatch[1].trim() : '',
1370
+ generatedAt: dateMatch ? dateMatch[1].trim() : '',
1371
+ updatedAt,
1372
+ requiresApproval: false,
1373
+ revisionFeedback: null,
1374
+ version: versionMatch ? parseInt(versionMatch[1]) : null,
1375
+ });
1376
+ }
1377
+ }
1378
+ }
1379
+ plans.sort((a, b) => (b.generatedAt || '').localeCompare(a.generatedAt || ''));
1380
+ return jsonReply(res, 200, plans);
1381
+ }
1382
+
1383
+ // GET /api/plans/archive/:file — read archived plan (checks prd/archive/ and plans/archive/)
1384
+ const archiveFileMatch = req.url.match(/^\/api\/plans\/archive\/([^?]+)$/);
1385
+ if (archiveFileMatch && req.method === 'GET') {
1386
+ const file = decodeURIComponent(archiveFileMatch[1]);
1387
+ if (file.includes('..')) return jsonReply(res, 400, { error: 'invalid' });
1388
+ // Check prd/archive/ first for .json, then plans/archive/ for .md
1389
+ const archiveDir = file.endsWith('.json') ? path.join(PRD_DIR, 'archive') : path.join(PLANS_DIR, 'archive');
1390
+ let content = safeRead(path.join(archiveDir, file));
1391
+ // Fallback: check the other archive dir
1392
+ if (!content) content = safeRead(path.join(file.endsWith('.json') ? path.join(PLANS_DIR, 'archive') : path.join(PRD_DIR, 'archive'), file));
1393
+ if (!content) return jsonReply(res, 404, { error: 'not found' });
1394
+ const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
1395
+ res.setHeader('Content-Type', contentType + '; charset=utf-8');
1396
+ res.setHeader('Access-Control-Allow-Origin', '*');
1397
+ res.end(content);
1398
+ return;
1399
+ }
1400
+
1401
+ // GET /api/plans/:file — read full plan (JSON from prd/ or markdown from plans/)
1402
+ const planFileMatch = req.url.match(/^\/api\/plans\/([^?]+)$/);
1403
+ if (planFileMatch && req.method === 'GET') {
1404
+ const file = decodeURIComponent(planFileMatch[1]);
1405
+ if (file.includes('..') || file.includes('/') || file.includes('\\')) return jsonReply(res, 400, { error: 'invalid' });
1406
+ let content = safeRead(resolvePlanPath(file));
1407
+ // Fallback: check all directories (prd/, plans/, guides/, archives)
1408
+ if (!content) content = safeRead(path.join(PRD_DIR, file));
1409
+ if (!content) content = safeRead(path.join(PRD_DIR, 'guides', file));
1410
+ if (!content) content = safeRead(path.join(PLANS_DIR, file));
1411
+ if (!content) content = safeRead(path.join(PRD_DIR, 'archive', file));
1412
+ if (!content) content = safeRead(path.join(PLANS_DIR, 'archive', file));
1413
+ if (!content) return jsonReply(res, 404, { error: 'not found' });
1414
+ // Find the actual file path for Last-Modified header + expose resolved relative path
1415
+ const planCandidates = [resolvePlanPath(file), path.join(PRD_DIR, file), path.join(PRD_DIR, 'guides', file), path.join(PLANS_DIR, file), path.join(PRD_DIR, 'archive', file), path.join(PLANS_DIR, 'archive', file)];
1416
+ for (const p of planCandidates) { try { const st = fs.statSync(p); if (st) { res.setHeader('Last-Modified', st.mtime.toISOString()); res.setHeader('X-Resolved-Path', path.relative(MINIONS_DIR, p).replace(/\\/g, '/')); break; } } catch {} }
1417
+ const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
1418
+ res.setHeader('Content-Type', contentType + '; charset=utf-8');
1419
+ res.setHeader('Cache-Control', 'no-cache');
1420
+ res.setHeader('Access-Control-Allow-Origin', '*');
1421
+ res.end(content);
1422
+ return;
1423
+ }
1424
+
1425
+ // POST /api/plans/approve — approve a plan for execution
1426
+ if (req.method === 'POST' && req.url === '/api/plans/approve') {
1427
+ try {
1428
+ const body = await readBody(req);
1429
+ if (!body.file) return jsonReply(res, 400, { error: 'file required' });
1430
+ const planPath = resolvePlanPath(body.file);
1431
+ const plan = JSON.parse(safeRead(planPath) || '{}');
1432
+ plan.status = 'approved';
1433
+ plan.approvedAt = new Date().toISOString();
1434
+ plan.approvedBy = body.approvedBy || os.userInfo().username;
1435
+ delete plan.pausedAt;
1436
+ safeWrite(planPath, plan);
1437
+
1438
+ // Resume paused work items across all projects
1439
+ let resumed = 0;
1440
+ const resumedItemIds = [];
1441
+ const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
1442
+ for (const proj of PROJECTS) {
1443
+ wiPaths.push(shared.projectWorkItemsPath(proj));
1444
+ }
1445
+ for (const wiPath of wiPaths) {
1446
+ try {
1447
+ const items = safeJson(wiPath);
1448
+ if (!items) continue;
1449
+ let changed = false;
1450
+ for (const w of items) {
1451
+ if (w.sourcePlan === body.file && w.status === 'paused' && w._pausedBy === 'prd-pause') {
1452
+ w.status = 'pending';
1453
+ delete w._pausedBy;
1454
+ w._resumedAt = new Date().toISOString();
1455
+ resumedItemIds.push(w.id);
1456
+ resumed++;
1457
+ changed = true;
1458
+ }
1459
+ }
1460
+ if (changed) safeWrite(wiPath, items);
1461
+ } catch {}
1462
+ }
1463
+
1464
+ // Clear dispatch completed entries for resumed items so they aren't dedup-blocked
1465
+ if (resumedItemIds.length > 0) {
1466
+ const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
1467
+ const resumedSet = new Set(resumedItemIds);
1468
+ mutateJsonFileLocked(dispatchPath, (dispatch) => {
1469
+ dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
1470
+ dispatch.completed = dispatch.completed.filter(d => !resumedSet.has(d.meta?.item?.id));
1471
+ return dispatch;
1472
+ }, { defaultValue: { pending: [], active: [], completed: [] } });
1473
+ }
1474
+
1475
+ invalidateStatusCache();
1476
+ return jsonReply(res, 200, { ok: true, status: 'approved', resumedWorkItems: resumed });
1477
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1478
+ }
1479
+
1480
+ // POST /api/plans/pause — pause a plan (stops new item materialization + pauses work items)
1481
+ if (req.method === 'POST' && req.url === '/api/plans/pause') {
1482
+ try {
1483
+ const body = await readBody(req);
1484
+ if (!body.file) return jsonReply(res, 400, { error: 'file required' });
1485
+ const planPath = resolvePlanPath(body.file);
1486
+ const plan = JSON.parse(safeRead(planPath) || '{}');
1487
+ plan.status = 'paused';
1488
+ plan.pausedAt = new Date().toISOString();
1489
+ safeWrite(planPath, plan);
1490
+
1491
+ // Propagate pause to materialized work items across all projects
1492
+ // But skip items that already have active PRs — those are past the point of pausing
1493
+ let paused = 0;
1494
+ const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
1495
+ const allPrItemIds = new Set();
1496
+ for (const proj of PROJECTS) {
1497
+ wiPaths.push(shared.projectWorkItemsPath(proj));
1498
+ try {
1499
+ const prs = safeJson(shared.projectPrPath(proj)) || [];
1500
+ for (const pr of prs) {
1501
+ if (pr.status === 'active' && pr.prdItems?.length) {
1502
+ pr.prdItems.forEach(id => allPrItemIds.add(id));
1503
+ }
1504
+ }
1505
+ } catch {}
1506
+ }
1507
+ const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
1508
+ const dispatch = JSON.parse(safeRead(dispatchPath) || '{}');
1509
+ const killedAgents = [];
1510
+
1511
+ for (const wiPath of wiPaths) {
1512
+ try {
1513
+ const items = safeJson(wiPath);
1514
+ if (!items) continue;
1515
+ let changed = false;
1516
+ for (const w of items) {
1517
+ if (w.sourcePlan !== body.file) continue;
1518
+ // Don't pause if this item already has an active PR
1519
+ if (allPrItemIds.has(w.id) || allPrItemIds.has(w.sourcePlanItem)) continue;
1520
+
1521
+ if (w.status === 'pending') {
1522
+ w.status = 'paused';
1523
+ w._pausedBy = 'prd-pause';
1524
+ paused++;
1525
+ changed = true;
1526
+ } else if (w.status === 'dispatched') {
1527
+ // Kill the agent working on this item
1528
+ const activeEntry = (dispatch.active || []).find(d => d.meta?.dispatchKey?.includes(w.id));
1529
+ if (activeEntry) {
1530
+ const statusPath = path.join(MINIONS_DIR, 'agents', activeEntry.agent, 'status.json');
1531
+ try {
1532
+ const agentStatus = JSON.parse(safeRead(statusPath) || '{}');
1533
+ if (agentStatus.pid) {
1534
+ if (process.platform === 'win32') {
1535
+ try { require('child_process').execSync('taskkill /PID ' + agentStatus.pid + ' /F /T', { stdio: 'pipe', timeout: 5000 }); } catch {}
1536
+ } else {
1537
+ try { process.kill(agentStatus.pid, 'SIGTERM'); } catch {}
1538
+ }
1539
+ }
1540
+ agentStatus.status = 'idle';
1541
+ delete agentStatus.currentTask;
1542
+ delete agentStatus.dispatched;
1543
+ safeWrite(statusPath, agentStatus);
1544
+ } catch {}
1545
+ killedAgents.push(activeEntry.agent);
1546
+ }
1547
+ w.status = 'paused';
1548
+ w._pausedBy = 'prd-pause';
1549
+ paused++;
1550
+ changed = true;
1551
+ }
1552
+ }
1553
+ if (changed) safeWrite(wiPath, items);
1554
+ } catch {}
1555
+ }
1556
+
1557
+ // Remove killed agents from dispatch active
1558
+ if (killedAgents.length > 0) {
1559
+ const killedSet = new Set(killedAgents);
1560
+ mutateJsonFileLocked(dispatchPath, (dp) => {
1561
+ dp.active = Array.isArray(dp.active) ? dp.active : [];
1562
+ dp.active = dp.active.filter(d => !killedSet.has(d.agent));
1563
+ return dp;
1564
+ }, { defaultValue: { pending: [], active: [], completed: [] } });
1565
+ }
1566
+
1567
+ invalidateStatusCache();
1568
+ return jsonReply(res, 200, { ok: true, status: 'paused', pausedWorkItems: paused });
1569
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1570
+ }
1571
+
1572
+ // POST /api/prd/regenerate — regenerate PRD from revised source plan
1573
+ if (req.method === 'POST' && req.url === '/api/prd/regenerate') {
1574
+ try {
1575
+ const body = await readBody(req);
1576
+ if (!body.file) return jsonReply(res, 400, { error: 'file is required' });
1577
+ if (body.file.includes('..')) return jsonReply(res, 400, { error: 'invalid file path' });
1578
+
1579
+ const prdPath = path.join(PRD_DIR, body.file);
1580
+ const plan = safeJson(prdPath);
1581
+ if (!plan) return jsonReply(res, 404, { error: 'PRD file not found' });
1582
+ if (!plan.source_plan) return jsonReply(res, 400, { error: 'PRD has no source_plan — cannot regenerate' });
1583
+
1584
+ const sourcePlanPath = path.join(PLANS_DIR, plan.source_plan);
1585
+ if (!fs.existsSync(sourcePlanPath)) return jsonReply(res, 400, { error: `Source plan not found: ${plan.source_plan}` });
1586
+
1587
+ // Collect completed item IDs from the old PRD to carry over
1588
+ const completedStatuses = new Set(['done', 'in-pr', 'implemented']); // in-pr kept for backward compat
1589
+ const completedItems = (plan.missing_features || [])
1590
+ .filter(f => completedStatuses.has(f.status))
1591
+ .map(f => ({ id: f.id, name: f.name, status: f.status }));
1592
+
1593
+ // Clean pending/failed work items from old PRD (keep done items)
1594
+ const { getProjects, projectWorkItemsPath } = shared;
1595
+ const config = queries.getConfig();
1596
+ for (const p of getProjects(config)) {
1597
+ const projWiPath = projectWorkItemsPath(p);
1598
+ const projItems = safeJson(projWiPath);
1599
+ if (!projItems) continue;
1600
+ const filtered = projItems.filter(w => {
1601
+ if (w.sourcePlan !== body.file) return true; // different plan, keep
1602
+ return completedStatuses.has(w.status); // keep completed, remove pending/failed
1603
+ });
1604
+ if (filtered.length < projItems.length) safeWrite(projWiPath, filtered);
1605
+ }
1606
+
1607
+ // Delete old PRD — agent will write replacement at same path
1608
+ try { fs.unlinkSync(prdPath); } catch {}
1609
+
1610
+ // Queue plan-to-prd regeneration with instructions to preserve completed items
1611
+ const wiPath = path.join(MINIONS_DIR, 'work-items.json');
1612
+ let items = [];
1613
+ const existing = safeRead(wiPath);
1614
+ if (existing) { try { items = JSON.parse(existing); } catch {} }
1615
+
1616
+ // Dedup: check if already queued
1617
+ const alreadyQueued = items.find(w =>
1618
+ w.type === 'plan-to-prd' && w.planFile === plan.source_plan && (w.status === 'pending' || w.status === 'dispatched')
1619
+ );
1620
+ if (alreadyQueued) return jsonReply(res, 200, { id: alreadyQueued.id, alreadyQueued: true });
1621
+
1622
+ const completedContext = completedItems.length > 0
1623
+ ? `\n\n**Previously completed items (preserve their status in the new PRD):**\n${completedItems.map(i => `- ${i.id}: ${i.name} [${i.status}]`).join('\n')}`
1624
+ : '';
1625
+
1626
+ const id = 'W-' + shared.uid();
1627
+ items.push({
1628
+ id, title: `Regenerate PRD: ${plan.plan_summary || plan.source_plan}`,
1629
+ type: 'plan-to-prd', priority: 'high',
1630
+ description: `Plan file: plans/${plan.source_plan}\nTarget PRD filename: ${body.file}\nRegeneration requested by user after plan revision.${completedContext}`,
1631
+ status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard:regenerate',
1632
+ project: plan.project || '', planFile: plan.source_plan,
1633
+ _targetPrdFile: body.file,
1634
+ });
1635
+ safeWrite(wiPath, items);
1636
+ return jsonReply(res, 200, { id, file: plan.source_plan });
1637
+ } catch (e) { return jsonReply(res, 500, { error: e.message }); }
1638
+ }
1639
+
1640
+ // POST /api/plans/execute — queue plan-to-prd conversion for a .md plan
1641
+ if (req.method === 'POST' && req.url === '/api/plans/execute') {
1642
+ try {
1643
+ const body = await readBody(req);
1644
+ if (!body.file) return jsonReply(res, 400, { error: 'file required' });
1645
+ if (!body.file.endsWith('.md')) return jsonReply(res, 400, { error: 'only .md plans can be executed' });
1646
+ const planPath = path.join(MINIONS_DIR, 'plans', body.file);
1647
+ if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
1648
+
1649
+ // Check if already queued
1650
+ const centralPath = path.join(MINIONS_DIR, 'work-items.json');
1651
+ const items = JSON.parse(safeRead(centralPath) || '[]');
1652
+ const existing = items.find(w => w.type === 'plan-to-prd' && w.planFile === body.file && (w.status === 'pending' || w.status === 'dispatched'));
1653
+ if (existing) return jsonReply(res, 200, { ok: true, id: existing.id, alreadyQueued: true });
1654
+
1655
+ const id = 'W-' + shared.uid();
1656
+ items.push({
1657
+ id, title: 'Convert plan to PRD: ' + body.file.replace('.md', ''),
1658
+ type: 'plan-to-prd', priority: 'high',
1659
+ description: 'Plan file: plans/' + body.file,
1660
+ status: 'pending', created: new Date().toISOString(),
1661
+ createdBy: 'dashboard:execute', project: body.project || '',
1662
+ planFile: body.file,
1663
+ });
1664
+ safeWrite(centralPath, items);
1665
+ invalidateStatusCache();
1666
+ return jsonReply(res, 200, { ok: true, id });
1667
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1668
+ }
1669
+
1670
+ // POST /api/plans/reject — reject a plan
1671
+ if (req.method === 'POST' && req.url === '/api/plans/reject') {
1672
+ try {
1673
+ const body = await readBody(req);
1674
+ if (!body.file) return jsonReply(res, 400, { error: 'file required' });
1675
+ const planPath = resolvePlanPath(body.file);
1676
+ const plan = JSON.parse(safeRead(planPath) || '{}');
1677
+ plan.status = 'rejected';
1678
+ plan.rejectedAt = new Date().toISOString();
1679
+ plan.rejectedBy = body.rejectedBy || os.userInfo().username;
1680
+ if (body.reason) plan.rejectionReason = body.reason;
1681
+ safeWrite(planPath, plan);
1682
+ return jsonReply(res, 200, { ok: true, status: 'rejected' });
1683
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1684
+ }
1685
+
1686
+ // POST /api/plans/regenerate — reset pending/failed work items for a plan so they re-materialize
1687
+ if (req.method === 'POST' && req.url === '/api/plans/regenerate') {
1688
+ try {
1689
+ const body = await readBody(req);
1690
+ if (!body.source) return jsonReply(res, 400, { error: 'source required' });
1691
+ const planPath = resolvePlanPath(body.source);
1692
+ if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
1693
+ const plan = safeJson(planPath);
1694
+ const planItems = plan.missing_features || [];
1695
+
1696
+ let reset = 0, kept = 0, newCount = 0;
1697
+ const deletedItemIds = [];
1698
+
1699
+ // Scan all work item sources for materialized items from this plan
1700
+ const wiPaths = [{ path: path.join(MINIONS_DIR, 'work-items.json'), label: 'central' }];
1701
+ for (const proj of PROJECTS) {
1702
+ wiPaths.push({ path: shared.projectWorkItemsPath(proj), label: proj.name });
1703
+ }
1704
+
1705
+ // Track which plan items have materialized work items
1706
+ const materializedPlanItemIds = new Set();
1707
+
1708
+ for (const wiInfo of wiPaths) {
1709
+ try {
1710
+ const items = safeJson(wiInfo.path);
1711
+ const filtered = [];
1712
+ for (const w of items) {
1713
+ if (w.sourcePlan === body.source) {
1714
+ materializedPlanItemIds.add(w.id);
1715
+ if (w.status === 'pending' || w.status === 'failed') {
1716
+ // Delete — will re-materialize on next tick with updated plan data
1717
+ reset++;
1718
+ deletedItemIds.push(w.id);
1719
+ } else {
1720
+ // dispatched or done — leave alone
1721
+ kept++;
1722
+ filtered.push(w);
1723
+ }
1724
+ } else {
1725
+ filtered.push(w);
1726
+ }
1727
+ }
1728
+ if (filtered.length < items.length) {
1729
+ safeWrite(wiInfo.path, filtered);
1730
+ }
1731
+ } catch {}
1732
+ }
1733
+
1734
+ // Count plan items that have no work item yet (will auto-materialize)
1735
+ for (const pi of planItems) {
1736
+ if (!materializedPlanItemIds.has(pi.id)) newCount++;
1737
+ }
1738
+
1739
+ // Clean dispatch entries for deleted items
1740
+ for (const itemId of deletedItemIds) {
1741
+ cleanDispatchEntries(d =>
1742
+ d.meta?.item?.sourcePlan === body.source && d.meta?.item?.id === itemId
1743
+ );
1744
+ }
1745
+
1746
+ return jsonReply(res, 200, { ok: true, reset, kept, new: newCount });
1747
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1748
+ }
1749
+
1750
+ // POST /api/plans/delete — delete a plan file
1751
+ if (req.method === 'POST' && req.url === '/api/plans/delete') {
1752
+ try {
1753
+ const body = await readBody(req);
1754
+ if (!body.file) return jsonReply(res, 400, { error: 'file required' });
1755
+ if (body.file.includes('..') || body.file.includes('/') || body.file.includes('\\')) {
1756
+ return jsonReply(res, 400, { error: 'invalid filename' });
1757
+ }
1758
+ const planPath = resolvePlanPath(body.file);
1759
+ if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
1760
+ // Read PRD content before deleting to get source_plan for cleanup
1761
+ let prdSourcePlan = null;
1762
+ if (body.file.endsWith('.json')) {
1763
+ try { prdSourcePlan = JSON.parse(safeRead(planPath) || '{}').source_plan || null; } catch {}
1764
+ }
1765
+ safeUnlink(planPath);
1766
+
1767
+ // Clean up materialized work items from all projects + central
1768
+ let cleaned = 0;
1769
+ const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
1770
+ for (const proj of PROJECTS) {
1771
+ wiPaths.push(shared.projectWorkItemsPath(proj));
1772
+ }
1773
+ for (const wiPath of wiPaths) {
1774
+ try {
1775
+ const items = safeJson(wiPath);
1776
+ const filtered = items.filter(w => w.sourcePlan !== body.file);
1777
+ if (filtered.length < items.length) {
1778
+ cleaned += items.length - filtered.length;
1779
+ safeWrite(wiPath, filtered);
1780
+ }
1781
+ } catch {}
1782
+ }
1783
+
1784
+ // Clean up dispatch entries for this plan's items
1785
+ const dispatchCleaned = cleanDispatchEntries(d =>
1786
+ d.meta?.item?.sourcePlan === body.file ||
1787
+ d.meta?.planFile === body.file ||
1788
+ (d.task && d.task.includes(body.file))
1789
+ );
1790
+
1791
+ // If deleting a PRD .json, reset the plan-to-prd work item so the source .md reverts to draft
1792
+ if (prdSourcePlan) {
1793
+ try {
1794
+ const centralPath = path.join(MINIONS_DIR, 'work-items.json');
1795
+ const centralItems = safeJson(centralPath) || [];
1796
+ let changed = false;
1797
+ for (const w of centralItems) {
1798
+ if (w.type === 'plan-to-prd' && w.status === 'done' && w.planFile === prdSourcePlan) {
1799
+ w.status = 'cancelled';
1800
+ w._cancelledBy = 'prd-deleted';
1801
+ changed = true;
1802
+ }
1803
+ }
1804
+ if (changed) safeWrite(centralPath, centralItems);
1805
+ } catch {}
1806
+ }
1807
+
1808
+ invalidateStatusCache();
1809
+ return jsonReply(res, 200, { ok: true, cleanedWorkItems: cleaned, cleanedDispatches: dispatchCleaned });
1810
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1811
+ }
1812
+
1813
+ // POST /api/plans/revise — request revision with feedback, dispatches agent to revise
1814
+ if (req.method === 'POST' && req.url === '/api/plans/revise') {
1815
+ try {
1816
+ const body = await readBody(req);
1817
+ if (!body.file || !body.feedback) return jsonReply(res, 400, { error: 'file and feedback required' });
1818
+ const planPath = resolvePlanPath(body.file);
1819
+ const plan = JSON.parse(safeRead(planPath) || '{}');
1820
+ plan.status = 'revision-requested';
1821
+ plan.revision_feedback = body.feedback;
1822
+ plan.revisionRequestedAt = new Date().toISOString();
1823
+ plan.revisionRequestedBy = body.requestedBy || os.userInfo().username;
1824
+ safeWrite(planPath, plan);
1825
+
1826
+ // Create a work item to revise the plan
1827
+ const wiPath = path.join(MINIONS_DIR, 'work-items.json');
1828
+ let items = [];
1829
+ const existing = safeRead(wiPath);
1830
+ if (existing) { try { items = JSON.parse(existing); } catch {} }
1831
+ const id = 'W-' + shared.uid();
1832
+ items.push({
1833
+ id, title: 'Revise plan: ' + (plan.plan_summary || body.file),
1834
+ type: 'plan-to-prd', priority: 'high',
1835
+ description: 'Revision requested on plan file: ' + (body.file.endsWith('.json') ? 'prd/' : 'plans/') + body.file + '\n\nFeedback:\n' + body.feedback + '\n\nRevise the plan to address this feedback. Read the existing plan, apply the feedback, and overwrite the file with the updated version. Set status back to "awaiting-approval".',
1836
+ status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard:revision',
1837
+ project: plan.project || '',
1838
+ planFile: body.file,
1839
+ });
1840
+ safeWrite(wiPath, items);
1841
+ return jsonReply(res, 200, { ok: true, status: 'revision-requested', workItemId: id });
1842
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1843
+ }
1844
+
1845
+ // POST /api/plans/revise-and-regenerate — REMOVED: plan versioning now handled by /api/doc-chat
1846
+ // The "Replace old PRD" flow uses qaReplacePrd (frontend) which calls /api/plans/pause + /api/plans/regenerate + planExecute
1847
+ if (false && req.method === 'POST' && req.url === '/api/plans/revise-and-regenerate') {
1848
+ try {
1849
+ const body = await readBody(req);
1850
+ if (!body.source || !body.instruction) return jsonReply(res, 400, { error: 'source and instruction required' });
1851
+
1852
+ // Find the source plan .md file for this PRD
1853
+ // Convention: PRD JSON references plan via plan_summary containing the work item ID,
1854
+ // or the .md file has a matching name prefix
1855
+ const prdPath = path.join(PRD_DIR, body.source);
1856
+ if (!fs.existsSync(prdPath)) return jsonReply(res, 404, { error: 'PRD file not found' });
1857
+
1858
+ // Look for corresponding .md plan file
1859
+ let sourcePlanFile = null;
1860
+ const planFiles = safeReadDir(PLANS_DIR).filter(f => f.endsWith('.md'));
1861
+ if (body.sourcePlan) {
1862
+ // Explicit source plan provided
1863
+ sourcePlanFile = body.sourcePlan;
1864
+ } else {
1865
+ // Heuristic: find .md plan by matching prefix or by reading PRD's generated_from field
1866
+ const prd = JSON.parse(safeRead(prdPath) || '{}');
1867
+ if (prd.source_plan) {
1868
+ sourcePlanFile = prd.source_plan;
1869
+ } else {
1870
+ // Match by prefix: officeagent-2026-03-15.json → plan-*officeagent* or plan-w025*.md
1871
+ const prdBase = body.source.replace('.json', '');
1872
+ for (const f of planFiles) {
1873
+ // Check if plan file mentions the same project or was created around same time
1874
+ const content = safeRead(path.join(PLANS_DIR, f)) || '';
1875
+ if (content.includes(prd.project || '___nomatch___') || content.includes(prd.plan_summary?.slice(0, 40) || '___nomatch___')) {
1876
+ sourcePlanFile = f;
1877
+ break;
1878
+ }
1879
+ }
1880
+ // Last resort: most recent .md plan
1881
+ if (!sourcePlanFile && planFiles.length > 0) {
1882
+ sourcePlanFile = planFiles.sort((a, b) => {
1883
+ try { return fs.statSync(path.join(PLANS_DIR, b)).mtimeMs - fs.statSync(path.join(PLANS_DIR, a)).mtimeMs; } catch { return 0; }
1884
+ })[0];
1885
+ }
1886
+ }
1887
+ }
1888
+
1889
+ if (!sourcePlanFile) {
1890
+ return jsonReply(res, 404, { error: 'No source plan (.md) found for this PRD. You can edit the PRD JSON directly using "Edit Plan".' });
1891
+ }
1892
+
1893
+ const sourcePlanPath = path.join(PLANS_DIR, sourcePlanFile);
1894
+ const planContent = safeRead(sourcePlanPath);
1895
+ if (!planContent) return jsonReply(res, 404, { error: 'Source plan file not readable: ' + sourcePlanFile });
1896
+
1897
+ // Step 1: Steer the source plan with the user's instruction via CC
1898
+ const result = await ccDocCall({
1899
+ message: body.instruction,
1900
+ document: planContent,
1901
+ title: sourcePlanFile,
1902
+ filePath: 'plans/' + sourcePlanFile,
1903
+ selection: body.selection || '',
1904
+ canEdit: true,
1905
+ isJson: false,
1906
+ });
1907
+
1908
+ if (!result.content) {
1909
+ return jsonReply(res, 200, { ok: true, answer: result.answer, updated: false });
1910
+ }
1911
+
1912
+ // Save the revised plan
1913
+ safeWrite(sourcePlanPath, result.content);
1914
+
1915
+ // Step 2: Pause the old PRD so it stops materializing items
1916
+ const prd = JSON.parse(safeRead(prdPath) || '{}');
1917
+ prd.status = 'revision-requested';
1918
+ prd.revision_feedback = body.instruction;
1919
+ prd.revisionRequestedAt = new Date().toISOString();
1920
+ safeWrite(prdPath, prd);
1921
+
1922
+ // Step 3: Clean up pending/failed work items from old PRD
1923
+ let reset = 0, kept = 0;
1924
+ const wiPaths = [{ path: path.join(MINIONS_DIR, 'work-items.json'), label: 'central' }];
1925
+ for (const proj of PROJECTS) {
1926
+ wiPaths.push({ path: shared.projectWorkItemsPath(proj), label: proj.name });
1927
+ }
1928
+ const deletedItemIds = [];
1929
+ for (const wiInfo of wiPaths) {
1930
+ try {
1931
+ const items = safeJson(wiInfo.path);
1932
+ const filtered = [];
1933
+ for (const w of items) {
1934
+ if (w.sourcePlan === body.source) {
1935
+ if (w.status === 'pending' || w.status === 'failed') {
1936
+ reset++;
1937
+ deletedItemIds.push(w.id);
1938
+ } else {
1939
+ kept++;
1940
+ filtered.push(w);
1941
+ }
1942
+ } else {
1943
+ filtered.push(w);
1944
+ }
1945
+ }
1946
+ if (filtered.length < items.length) safeWrite(wiInfo.path, filtered);
1947
+ } catch {}
1948
+ }
1949
+ for (const itemId of deletedItemIds) {
1950
+ cleanDispatchEntries(d =>
1951
+ d.meta?.item?.sourcePlan === body.source && d.meta?.item?.id === itemId
1952
+ );
1953
+ }
1954
+
1955
+ // Step 4: Dispatch plan-to-prd to regenerate PRD from revised plan
1956
+ const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
1957
+ let centralItems = [];
1958
+ try { centralItems = JSON.parse(safeRead(centralWiPath) || '[]'); } catch {}
1959
+ const wiId = 'W-' + shared.uid();
1960
+ centralItems.push({
1961
+ id: wiId,
1962
+ title: 'Regenerate PRD from revised plan: ' + sourcePlanFile,
1963
+ type: 'plan-to-prd',
1964
+ priority: 'high',
1965
+ description: `The source plan \`${sourcePlanFile}\` has been revised. Convert it into a fresh PRD JSON.\n\nRevision instruction: ${body.instruction}\n\nRead the revised plan, generate updated PRD items (missing_features), and write to \`prd/${body.source}\`. Set status to "approved". Include \`"source_plan": "${sourcePlanFile}"\` in the JSON root.\n\nPreserve items that are already done (status "implemented" or "complete"). Reset or replace items that were pending/failed.`,
1966
+ status: 'pending',
1967
+ created: new Date().toISOString(),
1968
+ createdBy: 'dashboard:revise-and-regenerate',
1969
+ project: prd.project || '',
1970
+ planFile: sourcePlanFile,
1971
+ });
1972
+ safeWrite(centralWiPath, centralItems);
1973
+
1974
+ return jsonReply(res, 200, {
1975
+ ok: true,
1976
+ answer: result.answer,
1977
+ updated: true,
1978
+ sourcePlan: sourcePlanFile,
1979
+ prdPaused: true,
1980
+ reset,
1981
+ kept,
1982
+ workItemId: wiId,
1983
+ });
1984
+ } catch (e) { return jsonReply(res, 500, { error: e.message }); }
1985
+ }
1986
+
1987
+ // POST /api/plans/discuss — generate a plan discussion session script
1988
+ if (req.method === 'POST' && req.url === '/api/plans/discuss') {
1989
+ try {
1990
+ const body = await readBody(req);
1991
+ if (!body.file) return jsonReply(res, 400, { error: 'file required' });
1992
+ const planPath = resolvePlanPath(body.file);
1993
+ const planContent = safeRead(planPath);
1994
+ if (!planContent) return jsonReply(res, 404, { error: 'plan not found' });
1995
+
1996
+ const plan = JSON.parse(planContent);
1997
+ const projectName = plan.project || 'Unknown';
1998
+
1999
+ // Build the session launch script
2000
+ const sessionName = 'plan-review-' + body.file.replace(/\.json$/, '');
2001
+ const sysPrompt = `You are a Plan Advisor helping a human review and refine a feature plan before it gets dispatched to an agent minions.
2002
+
2003
+ ## Your Role
2004
+ - Help the user understand, question, and refine the plan
2005
+ - Accept feedback and update the plan accordingly
2006
+ - When the user is satisfied, write the approved plan back to disk
2007
+
2008
+ ## The Plan File
2009
+ Path: ${planPath}
2010
+ Project: ${projectName}
2011
+
2012
+ ## How This Works
2013
+ 1. The user will discuss the plan with you — answer questions, suggest changes
2014
+ 2. When they want changes, update the plan items (add/remove/reorder/modify)
2015
+ 3. When they say ANY of these (or similar intent):
2016
+ - "approve", "go", "ship it", "looks good", "lgtm"
2017
+ - "clear context and implement", "clear context and go"
2018
+ - "go build it", "start working", "dispatch", "execute"
2019
+ - "do it", "proceed", "let's go", "send it"
2020
+
2021
+ Then:
2022
+ a. Read the current plan file fresh from disk
2023
+ b. Update status to "approved", set approvedAt and approvedBy
2024
+ c. Write it back to ${planPath} using the Write tool
2025
+ d. Print exactly: "Plan approved and saved. The engine will dispatch work on the next tick. You can close this session."
2026
+ e. Then EXIT the session — use /exit or simply stop responding. The user does NOT need to interact further.
2027
+
2028
+ 4. If they say "reject" or "cancel":
2029
+ - Update status to "rejected"
2030
+ - Write it back
2031
+ - Confirm and exit.
2032
+
2033
+ ## Important
2034
+ - Always read the plan file fresh before writing (another process may have modified it)
2035
+ - Preserve all existing fields when writing back
2036
+ - Use the Write tool to save changes
2037
+ - You have full file access — you can also read the project codebase for context
2038
+ - When the user signals approval, ALWAYS write the file and exit. Do not ask for confirmation — their intent is clear.`;
2039
+
2040
+ const initialPrompt = `Here's the plan awaiting your review:
2041
+
2042
+ **${plan.plan_summary || body.file}**
2043
+ Project: ${projectName}
2044
+ Strategy: ${plan.branch_strategy || 'parallel'}
2045
+ Branch: ${plan.feature_branch || 'per-item'}
2046
+ Items: ${(plan.missing_features || []).length}
2047
+
2048
+ ${(plan.missing_features || []).map((f, i) =>
2049
+ `${i + 1}. **${f.id}: ${f.name}** (${f.estimated_complexity}, ${f.priority})${f.depends_on?.length ? ' → depends on: ' + f.depends_on.join(', ') : ''}
2050
+ ${f.description || ''}`
2051
+ ).join('\n\n')}
2052
+
2053
+ ${plan.open_questions?.length ? '\n**Open Questions:**\n' + plan.open_questions.map(q => '- ' + q).join('\n') : ''}
2054
+
2055
+ What would you like to discuss or change? When you're happy, say "approve" and I'll finalize it.`;
2056
+
2057
+ // Write session files
2058
+ const sessionDir = path.join(MINIONS_DIR, 'engine');
2059
+ const id = shared.uid();
2060
+ const sysFile = path.join(sessionDir, `plan-discuss-sys-${id}.md`);
2061
+ const promptFile = path.join(sessionDir, `plan-discuss-prompt-${id}.md`);
2062
+ safeWrite(sysFile, sysPrompt);
2063
+ safeWrite(promptFile, initialPrompt);
2064
+
2065
+ // Generate the launch command
2066
+ const cmd = `claude --system-prompt "$(cat '${sysFile.replace(/\\/g, '/')}')" --name "${sessionName}" --add-dir "${MINIONS_DIR.replace(/\\/g, '/')}" < "${promptFile.replace(/\\/g, '/')}"`;
2067
+
2068
+ // Also generate a PowerShell-friendly version
2069
+ const psCmd = `Get-Content "${promptFile}" | claude --system-prompt (Get-Content "${sysFile}" -Raw) --name "${sessionName}" --add-dir "${MINIONS_DIR}"`;
2070
+
2071
+ return jsonReply(res, 200, {
2072
+ ok: true,
2073
+ sessionName,
2074
+ command: cmd,
2075
+ psCommand: psCmd,
2076
+ sysFile,
2077
+ promptFile,
2078
+ planFile: body.file,
2079
+ });
2080
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2081
+ }
2082
+
2083
+ // POST /api/doc-chat — routes through CC session for minions-aware doc Q&A + editing
2084
+ if (req.method === 'POST' && req.url === '/api/doc-chat') {
2085
+ try {
2086
+ const body = await readBody(req);
2087
+ if (!body.message) return jsonReply(res, 400, { error: 'message required' });
2088
+ if (!body.document) return jsonReply(res, 400, { error: 'document required' });
2089
+
2090
+ const canEdit = !!body.filePath;
2091
+ const isJson = body.filePath?.endsWith('.json');
2092
+ let currentContent = body.document;
2093
+ let fullPath = null;
2094
+ if (canEdit) {
2095
+ fullPath = path.resolve(MINIONS_DIR, body.filePath);
2096
+ if (!fullPath.startsWith(path.resolve(MINIONS_DIR))) return jsonReply(res, 400, { error: 'path must be under minions directory' });
2097
+ const diskContent = safeRead(fullPath);
2098
+ if (diskContent !== null) currentContent = diskContent;
2099
+ }
2100
+
2101
+ const { answer, content, actions } = await ccDocCall({
2102
+ message: body.message, document: currentContent, title: body.title,
2103
+ filePath: body.filePath, selection: body.selection, canEdit, isJson,
2104
+ });
2105
+
2106
+ if (!content) return jsonReply(res, 200, { ok: true, answer, edited: false, actions });
2107
+
2108
+ if (isJson) {
2109
+ try { JSON.parse(content); } catch (e) {
2110
+ return jsonReply(res, 200, { ok: true, answer: answer + '\n\n(JSON invalid — not saved: ' + e.message + ')', edited: false, actions });
2111
+ }
2112
+ }
2113
+ if (canEdit && fullPath) {
2114
+ // Always save in-place — the engine's staleness detection handles PRD sync
2115
+ // if the source plan changes while an active PRD is running.
2116
+ safeWrite(fullPath, content);
2117
+ return jsonReply(res, 200, { ok: true, answer, edited: true, content, actions });
2118
+ }
2119
+ return jsonReply(res, 200, { ok: true, answer: answer + '\n\n(Read-only — changes not saved)', edited: false, actions });
2120
+ } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2121
+ }
2122
+
2123
+ // POST /api/inbox/persist — promote an inbox item to team notes
2124
+ if (req.method === 'POST' && req.url === '/api/inbox/persist') {
2125
+ try {
2126
+ const body = await readBody(req);
2127
+ const { name } = body;
2128
+ if (!name) return jsonReply(res, 400, { error: 'name required' });
2129
+
2130
+ const inboxPath = path.join(MINIONS_DIR, 'notes', 'inbox', name);
2131
+ const content = safeRead(inboxPath);
2132
+ if (!content) return jsonReply(res, 404, { error: 'inbox item not found' });
2133
+
2134
+ // Extract a title from the first heading or first line
2135
+ const titleMatch = content.match(/^#+ (.+)$/m);
2136
+ const title = titleMatch ? titleMatch[1].trim() : name.replace('.md', '');
2137
+
2138
+ // Append to notes.md as a new team note
2139
+ const notesPath = path.join(MINIONS_DIR, 'notes.md');
2140
+ let notes = safeRead(notesPath) || '# Minions Notes\n\n## Active Notes\n';
2141
+ const today = new Date().toISOString().slice(0, 10);
2142
+ const entry = `\n### ${today}: ${title}\n**By:** Persisted from inbox (${name})\n**What:** ${content.slice(0, 500)}\n\n---\n`;
2143
+
2144
+ const marker = '## Active Notes';
2145
+ const idx = notes.indexOf(marker);
2146
+ if (idx !== -1) {
2147
+ const insertAt = idx + marker.length;
2148
+ notes = notes.slice(0, insertAt) + '\n' + entry + notes.slice(insertAt);
2149
+ } else {
2150
+ notes += '\n' + entry;
2151
+ }
2152
+ safeWrite(notesPath, notes);
2153
+
2154
+ // Move to archive
2155
+ const archiveDir = path.join(MINIONS_DIR, 'notes', 'archive');
2156
+ if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
2157
+ try { const _c = safeRead(inboxPath); safeWrite(path.join(archiveDir, `persisted-${name}`), _c); safeUnlink(inboxPath); } catch {}
2158
+
2159
+ return jsonReply(res, 200, { ok: true, title });
2160
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2161
+ }
2162
+
2163
+ // POST /api/inbox/promote-kb — promote an inbox item to the knowledge base
2164
+ if (req.method === 'POST' && req.url === '/api/inbox/promote-kb') {
2165
+ try {
2166
+ const body = await readBody(req);
2167
+ const { name, category } = body;
2168
+ if (!name) return jsonReply(res, 400, { error: 'name required' });
2169
+ if (!category || !shared.KB_CATEGORIES.includes(category)) {
2170
+ return jsonReply(res, 400, { error: 'category required: ' + shared.KB_CATEGORIES.join(', ') });
2171
+ }
2172
+
2173
+ const inboxPath = path.join(MINIONS_DIR, 'notes', 'inbox', name);
2174
+ const content = safeRead(inboxPath);
2175
+ if (!content) return jsonReply(res, 404, { error: 'inbox item not found' });
2176
+
2177
+ // Add frontmatter if not present
2178
+ const today = new Date().toISOString().slice(0, 10);
2179
+ let kbContent = content;
2180
+ if (!content.startsWith('---')) {
2181
+ const titleMatch = content.match(/^#+ (.+)$/m);
2182
+ const title = titleMatch ? titleMatch[1].trim() : name.replace('.md', '');
2183
+ kbContent = `---\ntitle: ${title}\ncategory: ${category}\ndate: ${today}\nsource: inbox/${name}\n---\n\n${content}`;
2184
+ }
2185
+
2186
+ // Write to knowledge base
2187
+ const kbDir = path.join(MINIONS_DIR, 'knowledge', category);
2188
+ if (!fs.existsSync(kbDir)) fs.mkdirSync(kbDir, { recursive: true });
2189
+ const kbFile = path.join(kbDir, name);
2190
+ safeWrite(kbFile, kbContent);
2191
+
2192
+ // Move inbox item to archive
2193
+ const archiveDir = path.join(MINIONS_DIR, 'notes', 'archive');
2194
+ if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
2195
+ try { const _c = safeRead(inboxPath); safeWrite(path.join(archiveDir, `kb-${category}-${name}`), _c); safeUnlink(inboxPath); } catch {}
2196
+
2197
+ return jsonReply(res, 200, { ok: true, category, file: name });
2198
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2199
+ }
2200
+
2201
+ // POST /api/inbox/open — open inbox file in Windows explorer
2202
+ if (req.method === 'POST' && req.url === '/api/inbox/open') {
2203
+ try {
2204
+ const body = await readBody(req);
2205
+ const { name } = body;
2206
+ if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) {
2207
+ return jsonReply(res, 400, { error: 'invalid name' });
2208
+ }
2209
+ const filePath = path.join(MINIONS_DIR, 'notes', 'inbox', name);
2210
+ if (!fs.existsSync(filePath)) return jsonReply(res, 404, { error: 'file not found' });
2211
+
2212
+ const { execFile } = require('child_process');
2213
+ try {
2214
+ if (process.platform === 'win32') {
2215
+ execFile('explorer', ['/select,', filePath.replace(/\//g, '\\')]);
2216
+ } else if (process.platform === 'darwin') {
2217
+ execFile('open', ['-R', filePath]);
2218
+ } else {
2219
+ execFile('xdg-open', [path.dirname(filePath)]);
2220
+ }
2221
+ } catch (e) {
2222
+ return jsonReply(res, 500, { error: 'Could not open file manager: ' + e.message });
2223
+ }
2224
+ return jsonReply(res, 200, { ok: true });
2225
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2226
+ }
2227
+
2228
+ // POST /api/inbox/delete — delete an inbox note
2229
+ if (req.method === 'POST' && req.url === '/api/inbox/delete') {
2230
+ try {
2231
+ const body = await readBody(req);
2232
+ const { name } = body;
2233
+ if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) {
2234
+ return jsonReply(res, 400, { error: 'invalid name' });
2235
+ }
2236
+ const filePath = path.join(MINIONS_DIR, 'notes', 'inbox', name);
2237
+ if (!fs.existsSync(filePath)) return jsonReply(res, 404, { error: 'file not found' });
2238
+ safeUnlink(filePath);
2239
+ return jsonReply(res, 200, { ok: true });
2240
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2241
+ }
2242
+
2243
+ // GET /api/skill?file=<name>&source=claude-code|project:<name>&dir=<path>
2244
+ if (req.method === 'GET' && req.url.startsWith('/api/skill?')) {
2245
+ const params = new URL(req.url, 'http://localhost').searchParams;
2246
+ const file = params.get('file');
2247
+ const dir = params.get('dir');
2248
+ if (!file || file.includes('..')) { res.statusCode = 400; res.end('Invalid file'); return; }
2249
+
2250
+ let content = '';
2251
+ if (dir) {
2252
+ // Direct path from collectSkillFiles
2253
+ const fullPath = path.join(dir.replace(/\//g, path.sep), file);
2254
+ if (!fullPath.includes('..')) content = safeRead(fullPath) || '';
2255
+ }
2256
+ if (!content) {
2257
+ // Fallback: search Claude Code skills, then project skills
2258
+ const home = process.env.HOME || process.env.USERPROFILE || '';
2259
+ const claudePath = path.join(home, '.claude', 'skills', file.replace('.md', '').replace('SKILL', ''), 'SKILL.md');
2260
+ content = safeRead(claudePath) || '';
2261
+ if (!content) {
2262
+ const source = params.get('source') || '';
2263
+ if (source.startsWith('project:')) {
2264
+ const proj = PROJECTS.find(p => p.name === source.replace('project:', ''));
2265
+ if (proj) content = safeRead(path.join(proj.localPath, '.claude', 'skills', file)) || '';
2266
+ }
2267
+ }
2268
+ }
2269
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
2270
+ res.setHeader('Access-Control-Allow-Origin', '*');
2271
+ res.end(content || 'Skill not found.');
2272
+ return;
2273
+ }
2274
+
2275
+ // POST /api/projects/browse — open folder picker dialog, return selected path
2276
+ if (req.method === 'POST' && req.url === '/api/projects/browse') {
2277
+ try {
2278
+ const { execSync } = require('child_process');
2279
+ let selectedPath = '';
2280
+ if (process.platform === 'win32') {
2281
+ // PowerShell folder browser dialog
2282
+ const ps = `Add-Type -AssemblyName System.Windows.Forms; $f = New-Object System.Windows.Forms.FolderBrowserDialog; $f.Description = 'Select project folder'; $f.ShowNewFolderButton = $false; if ($f.ShowDialog() -eq 'OK') { $f.SelectedPath } else { '' }`;
2283
+ selectedPath = execSync(`powershell -NoProfile -Command "${ps}"`, { encoding: 'utf8', timeout: 120000, windowsHide: true }).trim();
2284
+ } else if (process.platform === 'darwin') {
2285
+ selectedPath = execSync(`osascript -e 'POSIX path of (choose folder with prompt "Select project folder")'`, { encoding: 'utf8', timeout: 120000 }).trim();
2286
+ } else {
2287
+ selectedPath = execSync(`zenity --file-selection --directory --title="Select project folder" 2>/dev/null`, { encoding: 'utf8', timeout: 120000 }).trim();
2288
+ }
2289
+ if (!selectedPath) return jsonReply(res, 200, { cancelled: true });
2290
+ return jsonReply(res, 200, { path: selectedPath.replace(/\\/g, '/') });
2291
+ } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2292
+ }
2293
+
2294
+ // POST /api/projects/add — auto-discover and add a project to config
2295
+ if (req.method === 'POST' && req.url === '/api/projects/add') {
2296
+ try {
2297
+ const body = await readBody(req);
2298
+ if (!body.path) return jsonReply(res, 400, { error: 'path required' });
2299
+ const target = path.resolve(body.path);
2300
+ if (!fs.existsSync(target)) return jsonReply(res, 400, { error: 'Directory not found: ' + target });
2301
+
2302
+ const configPath = path.join(MINIONS_DIR, 'config.json');
2303
+ const config = safeJson(configPath);
2304
+ if (!config.projects) config.projects = [];
2305
+
2306
+ // Check if already linked
2307
+ if (config.projects.find(p => path.resolve(p.localPath) === target)) {
2308
+ return jsonReply(res, 400, { error: 'Project already linked at ' + target });
2309
+ }
2310
+
2311
+ // Auto-discover from git repo
2312
+ const { execSync: ex } = require('child_process');
2313
+ const detected = { name: path.basename(target), _found: [] };
2314
+ try {
2315
+ const head = ex('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || git symbolic-ref HEAD', { cwd: target, encoding: 'utf8', timeout: 5000 }).trim();
2316
+ detected.mainBranch = head.replace('refs/remotes/origin/', '').replace('refs/heads/', '');
2317
+ } catch { detected.mainBranch = 'main'; }
2318
+ try {
2319
+ const remoteUrl = ex('git remote get-url origin', { cwd: target, encoding: 'utf8', timeout: 5000 }).trim();
2320
+ if (remoteUrl.includes('github.com')) {
2321
+ detected.repoHost = 'github';
2322
+ const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
2323
+ if (m) { detected.org = m[1]; detected.repoName = m[2]; }
2324
+ } else if (remoteUrl.includes('visualstudio.com') || remoteUrl.includes('dev.azure.com')) {
2325
+ detected.repoHost = 'ado';
2326
+ const m = remoteUrl.match(/https:\/\/([^.]+)\.visualstudio\.com[^/]*\/([^/]+)\/_git\/([^/\s]+)/) ||
2327
+ remoteUrl.match(/https:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/\s]+)/);
2328
+ if (m) { detected.org = m[1]; detected.project = m[2]; detected.repoName = m[3]; }
2329
+ }
2330
+ } catch {}
2331
+ try {
2332
+ const pkgPath = path.join(target, 'package.json');
2333
+ if (fs.existsSync(pkgPath)) {
2334
+ const pkg = safeJson(pkgPath);
2335
+ if (pkg.name) detected.name = pkg.name.replace(/^@[^/]+\//, '');
2336
+ }
2337
+ } catch {}
2338
+ let description = '';
2339
+ try {
2340
+ const claudeMd = path.join(target, 'CLAUDE.md');
2341
+ if (fs.existsSync(claudeMd)) {
2342
+ const lines = (safeRead(claudeMd) || '').split('\n').filter(l => l.trim() && !l.startsWith('#'));
2343
+ if (lines[0] && lines[0].length < 200) description = lines[0].trim();
2344
+ }
2345
+ } catch {}
2346
+
2347
+ const name = body.name || detected.name;
2348
+ const prUrlBase = detected.repoHost === 'github'
2349
+ ? (detected.org && detected.repoName ? `https://github.com/${detected.org}/${detected.repoName}/pull/` : '')
2350
+ : (detected.org && detected.project && detected.repoName
2351
+ ? `https://${detected.org}.visualstudio.com/DefaultCollection/${detected.project}/_git/${detected.repoName}/pullrequest/` : '');
2352
+
2353
+ const project = {
2354
+ name, description, localPath: target.replace(/\\/g, '/'),
2355
+ repoHost: detected.repoHost || 'ado', repositoryId: '',
2356
+ adoOrg: detected.org || '', adoProject: detected.project || '',
2357
+ repoName: detected.repoName || name, mainBranch: detected.mainBranch || 'main',
2358
+ prUrlBase,
2359
+ workSources: { pullRequests: { enabled: true, cooldownMinutes: 30 }, workItems: { enabled: true, cooldownMinutes: 0 } }
2360
+ };
2361
+
2362
+ config.projects.push(project);
2363
+ safeWrite(configPath, config);
2364
+ reloadConfig(); // Update in-memory project list immediately
2365
+
2366
+ // Create project-local state files
2367
+ const minionsDir = path.join(target, '.minions');
2368
+ if (!fs.existsSync(minionsDir)) fs.mkdirSync(minionsDir, { recursive: true });
2369
+ const stateFiles = { 'pull-requests.json': '[]', 'work-items.json': '[]' };
2370
+ for (const [f, content] of Object.entries(stateFiles)) {
2371
+ const fp = path.join(minionsDir, f);
2372
+ if (!fs.existsSync(fp)) safeWrite(fp, content);
2373
+ }
2374
+
2375
+ return jsonReply(res, 200, { ok: true, name, path: target, detected });
2376
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2377
+ }
2378
+
2379
+ // ── Command Center: persistent multi-turn session ──────────────────────────
2380
+
2381
+ // POST /api/command-center/new-session — clear active CC session
2382
+ if (req.method === 'POST' && req.url === '/api/command-center/new-session') {
2383
+ ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
2384
+ ccInFlight = false; // Reset concurrency guard so a stuck request doesn't block new sessions
2385
+ safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
2386
+ return jsonReply(res, 200, { ok: true });
2387
+ }
2388
+
2389
+ // POST /api/command-center — conversational command center with full minions context
2390
+ if (req.method === 'POST' && req.url === '/api/command-center') {
2391
+ try {
2392
+ const body = await readBody(req);
2393
+ if (!body.message) return jsonReply(res, 400, { error: 'message required' });
2394
+
2395
+ // Concurrency guard — only one CC call at a time, with auto-release for stuck requests
2396
+ if (ccInFlight && (Date.now() - ccInFlightSince) < CC_INFLIGHT_TIMEOUT_MS) {
2397
+ return jsonReply(res, 429, { error: 'Command Center is busy — wait for the current request to finish.' });
2398
+ }
2399
+ if (ccInFlight) console.log('[CC] Auto-releasing stuck in-flight guard after timeout');
2400
+ ccInFlight = true;
2401
+ ccInFlightSince = Date.now();
2402
+
2403
+ try {
2404
+ if (body.sessionId && body.sessionId !== ccSession.sessionId) {
2405
+ ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
2406
+ }
2407
+ const wasResume = !!(body.sessionId && body.sessionId === ccSession.sessionId && ccSessionValid());
2408
+
2409
+ const result = await ccCall(body.message, { store: 'cc' });
2410
+
2411
+ if (result.code !== 0 || !result.text) {
2412
+ const debugInfo = result.code !== 0 ? `(exit code ${result.code})` : '(empty response)';
2413
+ const stderrTail = (result.stderr || '').trim().split('\n').filter(Boolean).slice(-3).join(' | ');
2414
+ console.error(`[CC] LLM failed after retries ${debugInfo}: ${stderrTail}`);
2415
+ const hasSession = !!ccSession.sessionId;
2416
+ const retryHint = hasSession
2417
+ ? 'Your session is still active — just send your message again to retry.'
2418
+ : 'Try clicking **New Session** and sending your message again.';
2419
+ return jsonReply(res, 200, {
2420
+ text: `I had trouble processing that ${debugInfo}. ${stderrTail ? 'Detail: ' + stderrTail : ''}\n\n${retryHint}`,
2421
+ actions: [], sessionId: ccSession.sessionId
2422
+ });
2423
+ }
2424
+
2425
+ return jsonReply(res, 200, { ...parseCCActions(result.text), sessionId: ccSession.sessionId, newSession: !wasResume });
2426
+ } finally {
2427
+ ccInFlight = false;
2428
+ ccInFlightSince = 0;
2429
+ }
2430
+ } catch (e) { ccInFlight = false; return jsonReply(res, 500, { error: e.message }); }
2431
+ }
2432
+
2433
+ // POST /api/engine/restart — force-kill engine and restart immediately
2434
+ if (req.method === 'POST' && req.url === '/api/engine/restart') {
2435
+ try {
2436
+ const newPid = restartEngine();
2437
+ return jsonReply(res, 200, { ok: true, pid: newPid });
2438
+ } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2439
+ }
2440
+
2441
+ // GET /api/settings — return current engine + claude + routing config
2442
+ if (req.method === 'GET' && req.url === '/api/settings') {
2443
+ try {
2444
+ const config = queries.getConfig();
2445
+ const routing = safeRead(path.join(MINIONS_DIR, 'routing.md')) || '';
2446
+ return jsonReply(res, 200, {
2447
+ engine: config.engine || {},
2448
+ claude: config.claude || {},
2449
+ agents: config.agents || {},
2450
+ routing,
2451
+ });
2452
+ } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2453
+ }
2454
+
2455
+ // POST /api/settings — update engine + claude + agent config
2456
+ if (req.method === 'POST' && req.url === '/api/settings') {
2457
+ try {
2458
+ const body = await readBody(req);
2459
+ const configPath = path.join(MINIONS_DIR, 'config.json');
2460
+ const config = safeJson(configPath);
2461
+
2462
+ if (body.engine) {
2463
+ // Validate and apply engine settings
2464
+ const e = body.engine;
2465
+ const D = shared.ENGINE_DEFAULTS;
2466
+ if (e.tickInterval !== undefined) config.engine.tickInterval = Math.max(10000, Number(e.tickInterval) || D.tickInterval);
2467
+ if (e.maxConcurrent !== undefined) config.engine.maxConcurrent = Math.max(1, Math.min(10, Number(e.maxConcurrent) || D.maxConcurrent));
2468
+ if (e.inboxConsolidateThreshold !== undefined) config.engine.inboxConsolidateThreshold = Math.max(1, Number(e.inboxConsolidateThreshold) || D.inboxConsolidateThreshold);
2469
+ if (e.agentTimeout !== undefined) config.engine.agentTimeout = Math.max(60000, Number(e.agentTimeout) || D.agentTimeout);
2470
+ if (e.maxTurns !== undefined) config.engine.maxTurns = Math.max(5, Math.min(500, Number(e.maxTurns) || D.maxTurns));
2471
+ if (e.heartbeatTimeout !== undefined) config.engine.heartbeatTimeout = Math.max(60000, Number(e.heartbeatTimeout) || D.heartbeatTimeout);
2472
+ if (e.worktreeCreateTimeout !== undefined) config.engine.worktreeCreateTimeout = Math.max(60000, Number(e.worktreeCreateTimeout) || D.worktreeCreateTimeout);
2473
+ if (e.worktreeCreateRetries !== undefined) config.engine.worktreeCreateRetries = Math.max(0, Math.min(3, Number(e.worktreeCreateRetries) || D.worktreeCreateRetries));
2474
+ }
2475
+
2476
+ if (body.claude) {
2477
+ if (body.claude.allowedTools !== undefined) config.claude.allowedTools = String(body.claude.allowedTools);
2478
+ if (body.claude.outputFormat !== undefined) config.claude.outputFormat = String(body.claude.outputFormat);
2479
+ }
2480
+
2481
+ if (body.agents) {
2482
+ for (const [id, updates] of Object.entries(body.agents)) {
2483
+ if (!config.agents[id]) continue;
2484
+ if (updates.role !== undefined) config.agents[id].role = String(updates.role);
2485
+ if (updates.skills !== undefined) config.agents[id].skills = Array.isArray(updates.skills) ? updates.skills : String(updates.skills).split(',').map(s => s.trim()).filter(Boolean);
2486
+ }
2487
+ }
2488
+
2489
+ safeWrite(configPath, config);
2490
+ return jsonReply(res, 200, { ok: true, message: 'Settings saved. Restart engine for changes to take full effect.' });
2491
+ } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2492
+ }
2493
+
2494
+ // POST /api/settings/routing — update routing.md
2495
+ if (req.method === 'POST' && req.url === '/api/settings/routing') {
2496
+ try {
2497
+ const body = await readBody(req);
2498
+ if (!body.content) return jsonReply(res, 400, { error: 'content required' });
2499
+ safeWrite(path.join(MINIONS_DIR, 'routing.md'), body.content);
2500
+ return jsonReply(res, 200, { ok: true });
2501
+ } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2502
+ }
2503
+
2504
+ // GET /api/health — lightweight health check for monitoring
2505
+ if (req.method === 'GET' && req.url === '/api/health') {
2506
+ const engine = getEngineState();
2507
+ const agents = getAgents();
2508
+ const health = {
2509
+ status: engine.state === 'running' ? 'healthy' : engine.state === 'paused' ? 'degraded' : 'stopped',
2510
+ engine: { state: engine.state, pid: engine.pid },
2511
+ agents: agents.map(a => ({ id: a.id, name: a.name, status: a.status })),
2512
+ projects: PROJECTS.map(p => ({ name: p.name, reachable: fs.existsSync(p.localPath) })),
2513
+ uptime: process.uptime(),
2514
+ timestamp: new Date().toISOString()
2515
+ };
2516
+ return jsonReply(res, 200, health);
2517
+ }
2518
+
2519
+ const agentMatch = req.url.match(/^\/api\/agent\/([\w-]+)$/);
2520
+ if (agentMatch) {
2521
+ res.setHeader('Content-Type', 'application/json');
2522
+ res.setHeader('Access-Control-Allow-Origin', '*');
2523
+ try {
2524
+ res.end(JSON.stringify(getAgentDetail(agentMatch[1])));
2525
+ } catch (e) {
2526
+ res.statusCode = 500;
2527
+ res.end(JSON.stringify({ error: e.message }));
2528
+ }
2529
+ return;
2530
+ }
2531
+
2532
+ if (req.url === '/api/status') {
2533
+ try {
2534
+ return jsonReply(res, 200, getStatus(), req);
2535
+ } catch (e) {
2536
+ return jsonReply(res, 500, { error: e.message }, req);
2537
+ }
2538
+ }
2539
+
2540
+ // (duplicate /api/health removed — first handler above is the canonical one)
2541
+
2542
+ // Serve dashboard HTML with gzip + caching
2543
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
2544
+ res.setHeader('ETag', HTML_ETAG);
2545
+ res.setHeader('Cache-Control', 'no-cache'); // revalidate each time, but use 304 if unchanged
2546
+ if (req.headers['if-none-match'] === HTML_ETAG) {
2547
+ res.statusCode = 304;
2548
+ res.end();
2549
+ return;
2550
+ }
2551
+ const ae = req.headers['accept-encoding'] || '';
2552
+ if (ae.includes('gzip')) {
2553
+ res.setHeader('Content-Encoding', 'gzip');
2554
+ res.end(HTML_GZ);
2555
+ } else {
2556
+ res.end(HTML);
2557
+ }
2558
+ });
2559
+
2560
+ server.listen(PORT, '127.0.0.1', () => {
2561
+ console.log(`\n Minions Mission Control`);
2562
+ console.log(` -----------------------------------`);
2563
+ console.log(` http://localhost:${PORT}`);
2564
+ console.log(`\n Watching:`);
2565
+ console.log(` Minions dir: ${MINIONS_DIR}`);
2566
+ console.log(` Projects: ${PROJECTS.map(p => `${p.name} (${p.localPath})`).join(', ')}`);
2567
+ console.log(`\n Auto-refreshes every 4s. Ctrl+C to stop.\n`);
2568
+
2569
+ const { exec } = require('child_process');
2570
+ try {
2571
+ if (process.platform === 'win32') {
2572
+ exec(`start "" "http://localhost:${PORT}"`);
2573
+ } else if (process.platform === 'darwin') {
2574
+ exec(`open http://localhost:${PORT}`);
2575
+ } else {
2576
+ exec(`xdg-open http://localhost:${PORT}`);
2577
+ }
2578
+ } catch (e) {
2579
+ console.log(` Could not auto-open browser: ${e.message}`);
2580
+ console.log(` Please open http://localhost:${PORT} manually.`);
2581
+ }
2582
+
2583
+ // ─── Engine Watchdog ─────────────────────────────────────────────────────
2584
+ // Every 30s, check if engine PID is alive. If dead but control.json says
2585
+ // running, auto-restart it. Prevents silent engine death.
2586
+ const { execSync } = require('child_process');
2587
+ setInterval(() => {
2588
+ try {
2589
+ const control = getEngineState();
2590
+ if (control.state !== 'running' || !control.pid) return;
2591
+
2592
+ // Check if PID is alive
2593
+ let alive = false;
2594
+ try {
2595
+ if (process.platform === 'win32') {
2596
+ const out = execSync(`tasklist /FI "PID eq ${control.pid}" /NH`, { encoding: 'utf8', timeout: 3000 });
2597
+ alive = out.includes(String(control.pid));
2598
+ } else {
2599
+ process.kill(control.pid, 0); // signal 0 = check existence
2600
+ alive = true;
2601
+ }
2602
+ } catch { alive = false; }
2603
+
2604
+ if (!alive) {
2605
+ console.log(`[watchdog] Engine PID ${control.pid} is dead — auto-restarting...`);
2606
+ restartEngine();
2607
+ }
2608
+ } catch (e) {
2609
+ console.error(`[watchdog] Error: ${e.message}`);
2610
+ }
2611
+ }, 30000);
2612
+ console.log(` Engine watchdog: active (checks every 30s)`);
2613
+ });
2614
+
2615
+ server.on('error', e => {
2616
+ if (e.code === 'EADDRINUSE') {
2617
+ console.error(`\n Port ${PORT} already in use. Kill the existing process or change PORT.\n`);
2618
+ } else {
2619
+ console.error(e);
2620
+ }
2621
+ process.exit(1);
2622
+ });
2623
+