vibe-forge 0.4.0 → 0.8.2

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 (129) hide show
  1. package/.claude/commands/clear-attention.md +63 -63
  2. package/.claude/commands/compact-context.md +52 -0
  3. package/.claude/commands/configure-vcs.md +5 -5
  4. package/.claude/commands/forge.md +50 -3
  5. package/.claude/commands/need-help.md +77 -77
  6. package/.claude/commands/update-status.md +64 -64
  7. package/.claude/commands/worker-loop.md +106 -106
  8. package/.claude/hooks/worker-loop.js +37 -4
  9. package/.claude/scripts/setup-worker-loop.sh +45 -45
  10. package/.claude/settings.json +89 -0
  11. package/LICENSE +21 -21
  12. package/README.md +211 -232
  13. package/agents/aegis/personality.md +35 -1
  14. package/agents/anvil/personality.md +39 -1
  15. package/agents/architect/personality.md +26 -0
  16. package/agents/crucible/personality.md +54 -1
  17. package/agents/crucible-x/personality.md +210 -0
  18. package/agents/ember/personality.md +29 -1
  19. package/agents/flux/personality.md +248 -0
  20. package/agents/furnace/personality.md +52 -1
  21. package/agents/herald/personality.md +3 -1
  22. package/agents/loki/personality.md +108 -0
  23. package/agents/oracle/personality.md +284 -0
  24. package/agents/pixel/personality.md +140 -0
  25. package/agents/planning-hub/personality.md +222 -0
  26. package/agents/scribe/personality.md +3 -1
  27. package/agents/slag/personality.md +268 -0
  28. package/agents/{sentinel → temper}/personality.md +85 -9
  29. package/bin/cli.js +77 -30
  30. package/bin/dashboard/api/agents.js +333 -0
  31. package/bin/dashboard/api/dispatch.js +507 -0
  32. package/bin/dashboard/api/tasks.js +416 -0
  33. package/bin/dashboard/public/assets/index-BpHfsx1r.js +2 -0
  34. package/bin/dashboard/public/assets/index-QODv4Zn9.css +1 -0
  35. package/bin/dashboard/public/index.html +14 -0
  36. package/bin/dashboard/server.js +645 -0
  37. package/bin/forge-daemon.sh +176 -550
  38. package/bin/forge-setup.sh +28 -11
  39. package/bin/forge-spawn.sh +5 -5
  40. package/bin/forge.cmd +83 -83
  41. package/bin/forge.sh +210 -31
  42. package/config/agent-manifest.yaml +237 -243
  43. package/config/agents.json +207 -132
  44. package/config/task-types.yaml +111 -106
  45. package/context/agent-overrides/README.md +41 -0
  46. package/context/architecture.md +42 -0
  47. package/context/modern-conventions.md +129 -129
  48. package/docs/agents.md +473 -409
  49. package/docs/architecture.md +194 -162
  50. package/docs/commands.md +451 -388
  51. package/docs/security.md +195 -144
  52. package/package.json +38 -11
  53. package/src/lib/check-aliases.js +50 -0
  54. package/{bin → src}/lib/colors.sh +2 -1
  55. package/src/lib/config.sh +347 -0
  56. package/{bin → src}/lib/constants.sh +48 -13
  57. package/src/lib/daemon/budgets.sh +107 -0
  58. package/src/lib/daemon/dependencies.sh +146 -0
  59. package/src/lib/daemon/display.sh +128 -0
  60. package/src/lib/daemon/notifications.sh +273 -0
  61. package/src/lib/daemon/routing.sh +93 -0
  62. package/src/lib/daemon/state.sh +163 -0
  63. package/src/lib/daemon/sync.sh +103 -0
  64. package/{bin → src}/lib/database.sh +52 -0
  65. package/src/lib/frontmatter.js +106 -0
  66. package/src/lib/heimdall-setup.js +113 -0
  67. package/src/lib/heimdall.js +265 -0
  68. package/src/lib/index.sh +25 -0
  69. package/{bin → src}/lib/json.sh +7 -1
  70. package/{bin → src}/lib/terminal.js +7 -1
  71. package/.claude/settings.local.json +0 -33
  72. package/agents/forge-master/capabilities.md +0 -144
  73. package/agents/forge-master/context-template.md +0 -128
  74. package/agents/forge-master/personality.md +0 -138
  75. package/bin/lib/config.sh +0 -313
  76. package/config/task-template.md +0 -87
  77. package/context/forge-state.yaml +0 -19
  78. package/docs/TODO.md +0 -150
  79. package/docs/getting-started.md +0 -243
  80. package/docs/npm-publishing.md +0 -95
  81. package/docs/workflows/README.md +0 -32
  82. package/docs/workflows/azure-devops.md +0 -108
  83. package/docs/workflows/bitbucket.md +0 -104
  84. package/docs/workflows/git-only.md +0 -130
  85. package/docs/workflows/gitea.md +0 -168
  86. package/docs/workflows/github.md +0 -103
  87. package/docs/workflows/gitlab.md +0 -105
  88. package/docs/workflows.md +0 -454
  89. package/tasks/completed/ARCH-001-duplicate-agent-config.md +0 -121
  90. package/tasks/completed/ARCH-002-mixed-bash-node-implementation.md +0 -88
  91. package/tasks/completed/ARCH-003-worker-loop-hook-duplication.md +0 -77
  92. package/tasks/completed/ARCH-009-test-organization.md +0 -78
  93. package/tasks/completed/ARCH-011-jq-vs-nodejs-json.md +0 -94
  94. package/tasks/completed/ARCH-012-tmp-files-in-root.md +0 -71
  95. package/tasks/completed/ARCH-013-exit-code-constants.md +0 -65
  96. package/tasks/completed/ARCH-014-sed-incompatibility.md +0 -96
  97. package/tasks/completed/ARCH-015-docs-todo-tracking.md +0 -83
  98. package/tasks/completed/CLEAN-001.md +0 -38
  99. package/tasks/completed/CLEAN-003.md +0 -47
  100. package/tasks/completed/CLEAN-004.md +0 -56
  101. package/tasks/completed/CLEAN-005.md +0 -75
  102. package/tasks/completed/CLEAN-006.md +0 -47
  103. package/tasks/completed/CLEAN-007.md +0 -34
  104. package/tasks/completed/CLEAN-008.md +0 -49
  105. package/tasks/completed/CLEAN-012.md +0 -58
  106. package/tasks/completed/CLEAN-013.md +0 -45
  107. package/tasks/completed/SEC-001-sql-injection-fix.md +0 -58
  108. package/tasks/completed/SEC-002-notification-injection-fix.md +0 -45
  109. package/tasks/completed/SEC-003-eval-injection-fix.md +0 -54
  110. package/tasks/completed/SEC-004-pid-race-condition-fix.md +0 -49
  111. package/tasks/completed/SEC-005-worker-loop-path-fix.md +0 -51
  112. package/tasks/completed/SEC-006-eval-agent-names.md +0 -55
  113. package/tasks/completed/SEC-007-spawn-escaping.md +0 -67
  114. package/tasks/pending/ARCH-004-git-bash-detection-duplication.md +0 -72
  115. package/tasks/pending/ARCH-005-missing-src-directory.md +0 -95
  116. package/tasks/pending/ARCH-006-task-template-location.md +0 -64
  117. package/tasks/pending/ARCH-007-daemon-monolith.md +0 -91
  118. package/tasks/pending/ARCH-008-forge-master-vs-hub.md +0 -81
  119. package/tasks/pending/ARCH-010-missing-index-files.md +0 -84
  120. package/tasks/pending/CLEAN-002.md +0 -29
  121. package/tasks/pending/CLEAN-009.md +0 -31
  122. package/tasks/pending/CLEAN-010.md +0 -30
  123. package/tasks/pending/CLEAN-011.md +0 -30
  124. package/tasks/pending/CLEAN-014.md +0 -32
  125. package/tasks/review/task-001.md +0 -78
  126. /package/{bin → src}/lib/agents.sh +0 -0
  127. /package/{bin → src}/lib/util.sh +0 -0
  128. /package/{bin → src}/lib/vcs.js +0 -0
  129. /package/{context → templates}/project-context-template.md +0 -0
package/bin/cli.js CHANGED
@@ -17,12 +17,14 @@ const os = require('os');
17
17
  // Read version from package.json (single source of truth)
18
18
  const packageJson = require(path.join(__dirname, '..', 'package.json'));
19
19
  const VERSION = packageJson.version;
20
- const REPO_URL = 'https://github.com/SpasticPalate/vibe-forge.git';
20
+ const REPO_URL = 'https://github.com/sugar-crash-studios/vibe-forge.git';
21
21
  const FORGE_DIR = '_vibe-forge';
22
22
 
23
23
  // Colors for terminal output
24
- // NOTE: Intentionally self-contained - cli.js runs standalone via npx before
25
- // the rest of Vibe Forge is installed, so it cannot share with colors.sh
24
+ // NOTE: Intentionally duplicated from src/lib/colors.sh
25
+ // This is by design: cli.js runs standalone via npx before the rest of Vibe Forge
26
+ // is installed, so it cannot import colors.sh. If you update colors here,
27
+ // also update src/lib/colors.sh to keep them in sync.
26
28
  const colors = {
27
29
  reset: '\x1b[0m',
28
30
  red: '\x1b[31m',
@@ -63,15 +65,60 @@ function showHelp() {
63
65
  Commands:
64
66
  init Initialize Vibe Forge in the current project
65
67
  update Update Vibe Forge to the latest version
68
+ agents List all agents, roles, and aliases
66
69
  version Show version information
67
70
  help Show this help message
68
71
 
69
72
  Examples:
70
73
  npx vibe-forge init # Set up Forge in your project
71
74
  npx vibe-forge update # Update to latest version
75
+ npx vibe-forge agents # Show agent roster
72
76
  `);
73
77
  }
74
78
 
79
+ function showAgents() {
80
+ const agentsFile = path.join(__dirname, '..', 'config', 'agents.json');
81
+ let agents = {};
82
+ try {
83
+ const data = JSON.parse(fs.readFileSync(agentsFile, 'utf8'));
84
+ agents = data.agents || {};
85
+ } catch (e) {
86
+ logError('Could not read agents.json');
87
+ logInfo('Run from inside a Vibe Forge installation, or run: npx vibe-forge init');
88
+ return;
89
+ }
90
+
91
+ showBanner();
92
+ console.log(`${colors.yellow}Agent Roster${colors.reset}`);
93
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
94
+ console.log('');
95
+
96
+ const typeOrder = ['core', 'worker', 'specialist', 'advisor'];
97
+ const typeLabels = {
98
+ core: 'Core (Always Running)',
99
+ worker: 'Workers (Per-Task)',
100
+ specialist: 'Specialists (On-Demand)',
101
+ advisor: 'Advisors',
102
+ };
103
+
104
+ for (const type of typeOrder) {
105
+ const group = Object.entries(agents).filter(([, a]) => a.type === type);
106
+ if (group.length === 0) continue;
107
+ console.log(` ${colors.cyan}${typeLabels[type] || type}${colors.reset}`);
108
+ for (const [name, agent] of group) {
109
+ console.log(` ${agent.icon || ' '} ${colors.yellow}${name}${colors.reset} — ${agent.role}`);
110
+ if (agent.aliases && agent.aliases.length > 0) {
111
+ console.log(` aliases: ${agent.aliases.join(', ')}`);
112
+ }
113
+ }
114
+ console.log('');
115
+ }
116
+
117
+ console.log(`Spawn an agent:`);
118
+ console.log(` ${colors.yellow}./bin/forge.sh spawn <agent-name-or-alias>${colors.reset}`);
119
+ console.log('');
120
+ }
121
+
75
122
  function showVersion() {
76
123
  console.log(`Vibe Forge v${VERSION}`);
77
124
  }
@@ -106,33 +153,9 @@ function getBashPath() {
106
153
  if (!isWindows()) {
107
154
  return 'bash';
108
155
  }
109
-
110
- // Common Git Bash paths on Windows
111
- const paths = [
112
- 'C:\\Program Files\\Git\\bin\\bash.exe',
113
- 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
114
- path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe'),
115
- ];
116
-
117
- for (const p of paths) {
118
- if (fs.existsSync(p)) {
119
- return p;
120
- }
121
- }
122
-
123
- // Try to find via where command
124
- try {
125
- const gitPath = execSync('where git', { stdio: 'pipe' }).toString().trim().split('\n')[0];
126
- const gitDir = path.dirname(path.dirname(gitPath));
127
- const bashPath = path.join(gitDir, 'bin', 'bash.exe');
128
- if (fs.existsSync(bashPath)) {
129
- return bashPath;
130
- }
131
- } catch {
132
- // Ignore
133
- }
134
-
135
- return null;
156
+ // Delegate to shared terminal utility for Windows Git Bash detection
157
+ const { findGitBash } = require('../src/lib/terminal.js');
158
+ return findGitBash() || null;
136
159
  }
137
160
 
138
161
  function runBashScript(scriptPath, args = []) {
@@ -201,6 +224,9 @@ async function initCommand() {
201
224
 
202
225
  log('');
203
226
 
227
+ // Validate agents.json (alias collisions)
228
+ validateAgentsConfig(targetDir);
229
+
204
230
  // Update project's .gitignore to ignore tool internals but keep project data
205
231
  updateGitignore();
206
232
 
@@ -217,6 +243,24 @@ async function initCommand() {
217
243
  }
218
244
  }
219
245
 
246
+ function validateAgentsConfig(forgeDir) {
247
+ const checkScript = path.join(forgeDir, 'bin', 'lib', 'check-aliases.js');
248
+ if (!fs.existsSync(checkScript)) return;
249
+
250
+ try {
251
+ const agentsFile = path.join(forgeDir, 'config', 'agents.json');
252
+ execSync(`node "${checkScript}" "${agentsFile}"`, { stdio: 'pipe' });
253
+ const config = JSON.parse(fs.readFileSync(agentsFile, 'utf8'));
254
+ const agentCount = Object.keys(config.agents || {}).length;
255
+ logSuccess(`Agent config valid (${agentCount} agents, no alias collisions)`);
256
+ } catch (err) {
257
+ logError('Agent alias collisions detected in config/agents.json');
258
+ logInfo(err.stderr ? err.stderr.toString().trim() : 'Run node src/lib/check-aliases.js for details');
259
+ logError('Fix collisions before using Vibe Forge.');
260
+ process.exit(1);
261
+ }
262
+ }
263
+
220
264
  function updateGitignore() {
221
265
  const gitignorePath = path.join(process.cwd(), '.gitignore');
222
266
  const forgeIgnoreMarker = '# Vibe Forge';
@@ -305,6 +349,9 @@ async function main() {
305
349
  case 'update':
306
350
  await updateCommand();
307
351
  break;
352
+ case 'agents':
353
+ showAgents();
354
+ break;
308
355
  case 'version':
309
356
  case '--version':
310
357
  case '-v':
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Agents API - List and query agent status
3
+ *
4
+ * Reads agent status from:
5
+ * 1. SQLite database (if available) - daemon's aggregated view
6
+ * 2. JSON files in context/agent-status/ - direct file read fallback
7
+ *
8
+ * Endpoints:
9
+ * GET /api/agents - List all agents with their status
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ // Agent status directory
16
+ const AGENT_STATUS_DIR = 'context/agent-status';
17
+
18
+ // Known agents (for complete listing even if status file missing)
19
+ const KNOWN_AGENTS = [
20
+ 'planning-hub',
21
+ 'architect',
22
+ 'furnace',
23
+ 'anvil',
24
+ 'crucible',
25
+ 'temper',
26
+ 'ember',
27
+ 'scribe',
28
+ 'aegis',
29
+ 'pixel',
30
+ 'oracle',
31
+ 'loki'
32
+ ];
33
+
34
+ // Status display order
35
+ const STATUS_ORDER = {
36
+ 'working': 0,
37
+ 'testing': 1,
38
+ 'blocked': 2,
39
+ 'attention': 3,
40
+ 'idle': 4,
41
+ 'unknown': 5,
42
+ 'offline': 6
43
+ };
44
+
45
+ /**
46
+ * Try to read agent status from SQLite database
47
+ * @param {string} projectRoot - Project root directory
48
+ * @returns {Array|null} Agent statuses or null if DB not available
49
+ */
50
+ async function readFromDatabase(projectRoot) {
51
+ try {
52
+ // Check for better-sqlite3 (synchronous)
53
+ const Database = require('better-sqlite3');
54
+ const dbPath = path.join(projectRoot, '.forge', 'forge.db');
55
+
56
+ if (!fs.existsSync(dbPath)) {
57
+ return null;
58
+ }
59
+
60
+ const db = new Database(dbPath, { readonly: true });
61
+
62
+ try {
63
+ const rows = db.prepare(`
64
+ SELECT agent, status, task, message, updated_at
65
+ FROM agent_status
66
+ ORDER BY agent
67
+ `).all();
68
+
69
+ return rows.map(row => ({
70
+ agent: row.agent,
71
+ status: row.status || 'unknown',
72
+ task: row.task || null,
73
+ message: row.message || null,
74
+ updatedAt: row.updated_at || null,
75
+ source: 'database'
76
+ }));
77
+ } finally {
78
+ db.close();
79
+ }
80
+ } catch (err) {
81
+ // better-sqlite3 not available or DB error, fall back to files
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Read agent status from JSON file
88
+ * @param {string} filePath - Path to status file
89
+ * @returns {Object|null} Agent status or null
90
+ */
91
+ function readStatusFile(filePath) {
92
+ try {
93
+ const content = fs.readFileSync(filePath, 'utf8');
94
+ const data = JSON.parse(content);
95
+ const stats = fs.statSync(filePath);
96
+
97
+ return {
98
+ agent: data.agent || path.basename(filePath, '.json'),
99
+ status: data.status || 'unknown',
100
+ task: data.task || null,
101
+ message: data.message || null,
102
+ updatedAt: data.updated || stats.mtime.toISOString(),
103
+ source: 'file'
104
+ };
105
+ } catch (err) {
106
+ console.error(`[Agents] Error reading ${filePath}: ${err.message}`);
107
+ return null;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Read all agent statuses from files
113
+ * @param {string} projectRoot - Project root directory
114
+ * @returns {Array} Agent statuses
115
+ */
116
+ function readFromFiles(projectRoot) {
117
+ const statusDir = path.join(projectRoot, AGENT_STATUS_DIR);
118
+ const agents = [];
119
+
120
+ if (!fs.existsSync(statusDir)) {
121
+ return agents;
122
+ }
123
+
124
+ const files = fs.readdirSync(statusDir).filter(f => f.endsWith('.json'));
125
+
126
+ for (const file of files) {
127
+ const filePath = path.join(statusDir, file);
128
+ const status = readStatusFile(filePath);
129
+
130
+ if (status) {
131
+ agents.push(status);
132
+ }
133
+ }
134
+
135
+ return agents;
136
+ }
137
+
138
+ /**
139
+ * Get agent metadata (icon, role) from personality files
140
+ * @param {string} projectRoot - Project root directory
141
+ * @param {string} agentName - Agent name
142
+ * @returns {Object} Agent metadata
143
+ */
144
+ function getAgentMetadata(projectRoot, agentName) {
145
+ const personalityPath = path.join(projectRoot, 'agents', agentName, 'personality.md');
146
+
147
+ const defaults = {
148
+ icon: getDefaultIcon(agentName),
149
+ role: 'Agent',
150
+ color: getDefaultColor(agentName)
151
+ };
152
+
153
+ if (!fs.existsSync(personalityPath)) {
154
+ return defaults;
155
+ }
156
+
157
+ try {
158
+ const content = fs.readFileSync(personalityPath, 'utf8');
159
+
160
+ // Extract icon from first line or **Icon:** field
161
+ const iconMatch = content.match(/\*\*Icon:\*\*\s*(.+)/);
162
+ if (iconMatch) {
163
+ defaults.icon = iconMatch[1].trim();
164
+ }
165
+
166
+ // Extract role from **Role:** field
167
+ const roleMatch = content.match(/\*\*Role:\*\*\s*(.+)/);
168
+ if (roleMatch) {
169
+ defaults.role = roleMatch[1].trim();
170
+ }
171
+
172
+ return defaults;
173
+ } catch (err) {
174
+ return defaults;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Get default icon for agent
180
+ * @param {string} agentName - Agent name
181
+ * @returns {string} Icon emoji
182
+ */
183
+ function getDefaultIcon(agentName) {
184
+ const icons = {
185
+ 'planning-hub': '⚙️',
186
+ 'architect': '🏛️',
187
+ 'furnace': '🔥',
188
+ 'anvil': '🔨',
189
+ 'crucible': '🧪',
190
+ 'temper': '⚖️',
191
+ 'ember': '✨',
192
+ 'scribe': '📝',
193
+ 'aegis': '🔒',
194
+ 'pixel': '🎨',
195
+ 'oracle': '🔮',
196
+ 'loki': '🎭'
197
+ };
198
+ return icons[agentName] || '🤖';
199
+ }
200
+
201
+ /**
202
+ * Get default color for agent (for UI)
203
+ * @param {string} agentName - Agent name
204
+ * @returns {string} Color code
205
+ */
206
+ function getDefaultColor(agentName) {
207
+ const colors = {
208
+ 'planning-hub': '#6366f1',
209
+ 'architect': '#8b5cf6',
210
+ 'furnace': '#ef4444',
211
+ 'anvil': '#f97316',
212
+ 'crucible': '#22c55e',
213
+ 'temper': '#8b5cf6',
214
+ 'ember': '#eab308',
215
+ 'scribe': '#06b6d4',
216
+ 'aegis': '#14b8a6',
217
+ 'pixel': '#ec4899',
218
+ 'oracle': '#FBBF24',
219
+ 'loki': '#7C3AED'
220
+ };
221
+ return colors[agentName] || '#6b7280';
222
+ }
223
+
224
+ /**
225
+ * List all agents with their status
226
+ * @param {string} projectRoot - Project root directory
227
+ * @returns {Object} Agents listing with summary
228
+ */
229
+ async function listAgents(projectRoot) {
230
+ // Try database first, then fall back to files
231
+ let statuses = await readFromDatabase(projectRoot);
232
+
233
+ if (!statuses) {
234
+ statuses = readFromFiles(projectRoot);
235
+ }
236
+
237
+ // Create map for quick lookup
238
+ const statusMap = new Map();
239
+ for (const status of statuses) {
240
+ statusMap.set(status.agent, status);
241
+ }
242
+
243
+ // Build complete agent list
244
+ const agents = [];
245
+
246
+ for (const agentName of KNOWN_AGENTS) {
247
+ const status = statusMap.get(agentName);
248
+ const metadata = getAgentMetadata(projectRoot, agentName);
249
+
250
+ agents.push({
251
+ agent: agentName,
252
+ displayName: agentName.split('-').map(w =>
253
+ w.charAt(0).toUpperCase() + w.slice(1)
254
+ ).join(' '),
255
+ icon: metadata.icon,
256
+ role: metadata.role,
257
+ color: metadata.color,
258
+ status: status?.status || 'offline',
259
+ task: status?.task || null,
260
+ message: status?.message || null,
261
+ updatedAt: status?.updatedAt || null,
262
+ isOnline: status != null
263
+ });
264
+ }
265
+
266
+ // Add any unknown agents found in status files
267
+ for (const status of statuses) {
268
+ if (!KNOWN_AGENTS.includes(status.agent)) {
269
+ const metadata = getAgentMetadata(projectRoot, status.agent);
270
+ agents.push({
271
+ agent: status.agent,
272
+ displayName: status.agent.split('-').map(w =>
273
+ w.charAt(0).toUpperCase() + w.slice(1)
274
+ ).join(' '),
275
+ icon: metadata.icon,
276
+ role: metadata.role,
277
+ color: metadata.color,
278
+ status: status.status,
279
+ task: status.task,
280
+ message: status.message,
281
+ updatedAt: status.updatedAt,
282
+ isOnline: true
283
+ });
284
+ }
285
+ }
286
+
287
+ // Sort by status priority, then by name
288
+ agents.sort((a, b) => {
289
+ const sa = STATUS_ORDER[a.status] ?? 5;
290
+ const sb = STATUS_ORDER[b.status] ?? 5;
291
+ if (sa !== sb) return sa - sb;
292
+ return a.agent.localeCompare(b.agent);
293
+ });
294
+
295
+ // Build summary
296
+ const summary = {
297
+ total: agents.length,
298
+ online: agents.filter(a => a.isOnline).length,
299
+ working: agents.filter(a => a.status === 'working').length,
300
+ idle: agents.filter(a => a.status === 'idle').length,
301
+ blocked: agents.filter(a => a.status === 'blocked').length,
302
+ attention: agents.filter(a => a.status === 'attention').length
303
+ };
304
+
305
+ return {
306
+ agents,
307
+ summary,
308
+ dataSource: statuses.length > 0 && statuses[0].source || 'none'
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Get single agent details
314
+ * @param {string} projectRoot - Project root directory
315
+ * @param {string} agentName - Agent name
316
+ * @returns {Object|null} Agent details or null
317
+ */
318
+ async function getAgent(projectRoot, agentName) {
319
+ // Sanitize agent name
320
+ const safeName = agentName.toLowerCase().replace(/[^a-z0-9-]/g, '');
321
+
322
+ const result = await listAgents(projectRoot);
323
+ return result.agents.find(a => a.agent === safeName) || null;
324
+ }
325
+
326
+ module.exports = {
327
+ listAgents,
328
+ getAgent,
329
+ readFromFiles,
330
+ readFromDatabase,
331
+ getAgentMetadata,
332
+ KNOWN_AGENTS
333
+ };