@grantx/fleet-cli 0.1.0

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.
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@grantx/fleet-cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "files": ["src/"],
6
+ "bin": {
7
+ "fleet": "./src/cli.js"
8
+ },
9
+ "dependencies": {
10
+ "@grantx/fleet-core": "0.1.0"
11
+ },
12
+ "engines": {
13
+ "node": ">=22.0.0"
14
+ }
15
+ }
@@ -0,0 +1,91 @@
1
+ // agent-cmd.js — `fleet agent add|remove` subcommands.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { setupAgentWorkspaces } from './setup-agents.js';
7
+
8
+ export default async function agentCmd(args) {
9
+ const [action, name, ...rest] = args;
10
+
11
+ if (!action || !name) {
12
+ console.log('Usage:');
13
+ console.log(' fleet agent add <name> --role "..." Add an agent');
14
+ console.log(' fleet agent remove <name> Remove an agent');
15
+ process.exit(1);
16
+ }
17
+
18
+ const projectRoot = process.cwd();
19
+ const configPath = path.join(projectRoot, 'fleet.config.json');
20
+
21
+ if (!fs.existsSync(configPath)) {
22
+ console.error('Error: fleet.config.json not found. Run `fleet init` first.');
23
+ process.exit(1);
24
+ }
25
+
26
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
27
+
28
+ if (action === 'add') {
29
+ // Parse --role flag
30
+ const roleIdx = rest.indexOf('--role');
31
+ const role = roleIdx !== -1 ? rest[roleIdx + 1] : 'General purpose agent';
32
+
33
+ // Parse --keywords flag
34
+ const kwIdx = rest.indexOf('--keywords');
35
+ const keywords = kwIdx !== -1 ? rest[kwIdx + 1].split(',').map(k => k.trim()) : [];
36
+
37
+ if (config.agents.find(a => a.name === name)) {
38
+ console.error(`Agent "${name}" already exists.`);
39
+ process.exit(1);
40
+ }
41
+
42
+ const newAgent = {
43
+ name,
44
+ role,
45
+ keywords,
46
+ filePatterns: [],
47
+ isConductor: false,
48
+ };
49
+
50
+ config.agents.push(newAgent);
51
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
52
+
53
+ // Create workspace for new agent
54
+ setupAgentWorkspaces(projectRoot, [newAgent]);
55
+
56
+ // Add session entry
57
+ const sessFile = path.join(projectRoot, '.fleet', 'sessions.json');
58
+ let sessions = {};
59
+ try { sessions = JSON.parse(fs.readFileSync(sessFile, 'utf8')); } catch { /* new file */ }
60
+ if (!sessions[name]) {
61
+ sessions[name] = { id: randomUUID(), initialized: false };
62
+ fs.writeFileSync(sessFile, JSON.stringify(sessions, null, 2));
63
+ }
64
+
65
+ console.log(`Added agent "${name}" (role: ${role}).`);
66
+
67
+ } else if (action === 'remove') {
68
+ const idx = config.agents.findIndex(a => a.name === name);
69
+ if (idx === -1) {
70
+ console.error(`Agent "${name}" not found.`);
71
+ process.exit(1);
72
+ }
73
+
74
+ if (config.agents[idx].isConductor) {
75
+ console.error('Cannot remove the conductor agent.');
76
+ process.exit(1);
77
+ }
78
+
79
+ config.agents.splice(idx, 1);
80
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
81
+
82
+ // Keep workspace for safety (sessions, logs)
83
+ console.log(`Removed agent "${name}" from config.`);
84
+ console.log(` Workspace preserved at .fleet/agents/${name}/ (delete manually if unwanted).`);
85
+
86
+ } else {
87
+ console.error(`Unknown agent action: ${action}`);
88
+ console.error('Use: fleet agent add <name> | fleet agent remove <name>');
89
+ process.exit(1);
90
+ }
91
+ }
package/src/cli.js ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ // @grantx/fleet-cli — setup, init, and daemon management
3
+
4
+ const [,, command, ...args] = process.argv;
5
+
6
+ const COMMANDS = {
7
+ init: () => import('./init.js').then(m => m.default(args)),
8
+ start: () => import('./start.js').then(m => m.default(args)),
9
+ stop: () => import('./stop.js').then(m => m.default(args)),
10
+ agent: () => import('./agent-cmd.js').then(m => m.default(args)),
11
+ };
12
+
13
+ if (!command || command === '--help' || command === '-h') {
14
+ console.log(`
15
+ fleet — Claude Code agent fleet manager
16
+
17
+ Usage:
18
+ fleet init Initialize fleet in current project
19
+ fleet start Start daemon (background agent dispatch)
20
+ fleet stop Stop daemon
21
+ fleet agent add <name> --role "..." Add an agent to the roster
22
+ fleet agent remove <name> Remove an agent from the roster
23
+
24
+ Options:
25
+ --help, -h Show this help
26
+ --version Show version
27
+ `);
28
+ process.exit(0);
29
+ }
30
+
31
+ if (command === '--version') {
32
+ const { readFileSync } = await import('node:fs');
33
+ const { fileURLToPath } = await import('node:url');
34
+ const { dirname, join } = await import('node:path');
35
+ const __dirname = dirname(fileURLToPath(import.meta.url));
36
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
37
+ console.log(pkg.version);
38
+ process.exit(0);
39
+ }
40
+
41
+ if (!COMMANDS[command]) {
42
+ console.error(`Unknown command: ${command}\nRun 'fleet --help' for usage.`);
43
+ process.exit(1);
44
+ }
45
+
46
+ try {
47
+ await COMMANDS[command]();
48
+ } catch (err) {
49
+ console.error(`Error: ${err.message}`);
50
+ process.exit(1);
51
+ }
package/src/daemon.js ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ // daemon.js — Background daemon process.
3
+ // Runs ConductorLoop for autonomous dispatch.
4
+
5
+ import { loadConfig } from '@grantx/fleet-core/config';
6
+ import { SupabaseClient } from '@grantx/fleet-core/supabase-client';
7
+ import { AgentPool } from '@grantx/fleet-core/agent-runner';
8
+ import { ConductorLoop } from '@grantx/fleet-core/conductor-loop';
9
+ import { log } from '@grantx/fleet-core/logger';
10
+
11
+ const configArg = process.argv.indexOf('--config');
12
+ const configPath = configArg !== -1 ? process.argv[configArg + 1] : undefined;
13
+
14
+ const config = loadConfig(configPath);
15
+ const supabase = new SupabaseClient(config.supabase.url, config.supabase.key, config.teamId);
16
+ const agentPool = new AgentPool(config);
17
+ const loop = new ConductorLoop(agentPool, supabase, config);
18
+
19
+ log.info('daemon', `Starting fleet daemon (team: ${config.teamId}, agents: ${config.agents.length})`);
20
+ loop.start();
21
+
22
+ // Graceful shutdown
23
+ function shutdown(signal) {
24
+ log.info('daemon', `Received ${signal}, shutting down...`);
25
+ loop.stop();
26
+ agentPool.killAll();
27
+ process.exit(0);
28
+ }
29
+
30
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
31
+ process.on('SIGINT', () => shutdown('SIGINT'));
@@ -0,0 +1,421 @@
1
+ // generate.js — Codebase scanner and agent roster generator.
2
+ // Rule-based detection: no LLM calls. Deterministic output from manifest files.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ // ── Codebase Scanner ────────────────────────────────────────────────
8
+
9
+ /**
10
+ * Scan a project root and return structured metadata.
11
+ * @param {string} projectRoot
12
+ * @returns {{ languages, frameworks, infra, cloud, testFiles, fileStats, claudeMd }}
13
+ */
14
+ export function scanCodebase(projectRoot) {
15
+ const result = {
16
+ languages: [],
17
+ frameworks: [],
18
+ infra: [],
19
+ cloud: [],
20
+ testFiles: 0,
21
+ fileStats: {},
22
+ claudeMd: null,
23
+ };
24
+
25
+ // Detect languages from manifest files
26
+ const manifests = {
27
+ 'package.json': 'JavaScript/TypeScript',
28
+ 'pyproject.toml': 'Python',
29
+ 'requirements.txt': 'Python',
30
+ 'go.mod': 'Go',
31
+ 'Cargo.toml': 'Rust',
32
+ 'pom.xml': 'Java',
33
+ 'build.gradle': 'Java/Kotlin',
34
+ 'Gemfile': 'Ruby',
35
+ 'mix.exs': 'Elixir',
36
+ 'composer.json': 'PHP',
37
+ };
38
+
39
+ for (const [file, lang] of Object.entries(manifests)) {
40
+ if (fs.existsSync(path.join(projectRoot, file))) {
41
+ if (!result.languages.includes(lang)) {
42
+ result.languages.push(lang);
43
+ }
44
+ }
45
+ }
46
+
47
+ // Detect frameworks from dependencies
48
+ const deps = readDependencies(projectRoot);
49
+ const frameworkMap = {
50
+ // Python
51
+ 'fastapi': 'FastAPI', 'django': 'Django', 'flask': 'Flask',
52
+ 'lightgbm': 'LightGBM', 'xgboost': 'XGBoost', 'scikit-learn': 'scikit-learn',
53
+ 'torch': 'PyTorch', 'pytorch': 'PyTorch', 'tensorflow': 'TensorFlow',
54
+ 'pandas': 'pandas', 'numpy': 'NumPy', 'pydantic': 'Pydantic',
55
+ 'sqlalchemy': 'SQLAlchemy', 'alembic': 'Alembic',
56
+ // JavaScript/TypeScript
57
+ 'react': 'React', 'next': 'Next.js', 'vue': 'Vue', 'nuxt': 'Nuxt',
58
+ 'svelte': 'Svelte', 'angular': 'Angular',
59
+ 'express': 'Express', 'fastify': 'Fastify', 'hono': 'Hono',
60
+ 'trpc': 'tRPC', '@trpc/server': 'tRPC',
61
+ 'tailwindcss': 'Tailwind CSS', 'prisma': 'Prisma',
62
+ // Go
63
+ 'gin': 'Gin', 'echo': 'Echo', 'fiber': 'Fiber',
64
+ // Rust
65
+ 'actix-web': 'Actix', 'axum': 'Axum', 'rocket': 'Rocket',
66
+ };
67
+
68
+ for (const dep of deps) {
69
+ const normalizedDep = dep.toLowerCase();
70
+ for (const [key, fw] of Object.entries(frameworkMap)) {
71
+ if (normalizedDep.includes(key) && !result.frameworks.includes(fw)) {
72
+ result.frameworks.push(fw);
73
+ }
74
+ }
75
+ }
76
+
77
+ // Detect infra
78
+ const infraSignals = {
79
+ 'Dockerfile': 'Docker',
80
+ 'docker-compose.yml': 'Docker Compose',
81
+ 'docker-compose.yaml': 'Docker Compose',
82
+ '.github/workflows': 'GitHub Actions',
83
+ '.gitlab-ci.yml': 'GitLab CI',
84
+ 'Jenkinsfile': 'Jenkins',
85
+ };
86
+ for (const [file, name] of Object.entries(infraSignals)) {
87
+ const p = path.join(projectRoot, file);
88
+ if (fs.existsSync(p)) {
89
+ result.infra.push(name);
90
+ }
91
+ }
92
+ // Check for k8s and terraform dirs
93
+ if (dirHasFiles(path.join(projectRoot, 'k8s')) || dirHasFiles(path.join(projectRoot, 'kubernetes'))) {
94
+ result.infra.push('Kubernetes');
95
+ }
96
+ if (dirHasFiles(path.join(projectRoot, 'terraform')) || dirHasFiles(path.join(projectRoot, 'infra'))) {
97
+ result.infra.push('Terraform');
98
+ }
99
+
100
+ // Detect cloud from config/env
101
+ const cloudKeywords = {
102
+ 'gcloud': 'GCP', 'google-cloud': 'GCP', 'spanner': 'GCP',
103
+ 'cloud-run': 'GCP', 'cloud_run': 'GCP', 'bigquery': 'GCP',
104
+ 'aws': 'AWS', 'amazonaws': 'AWS', 's3': 'AWS', 'lambda': 'AWS',
105
+ 'azure': 'Azure', 'cosmos': 'Azure',
106
+ };
107
+ const configText = readConfigFiles(projectRoot);
108
+ for (const [keyword, cloud] of Object.entries(cloudKeywords)) {
109
+ if (configText.includes(keyword) && !result.cloud.includes(cloud)) {
110
+ result.cloud.push(cloud);
111
+ }
112
+ }
113
+
114
+ // Count files and test files
115
+ result.fileStats = countFiles(projectRoot);
116
+ result.testFiles = countTestFiles(projectRoot);
117
+
118
+ // Read CLAUDE.md if present
119
+ const claudeMdPath = path.join(projectRoot, 'CLAUDE.md');
120
+ if (fs.existsSync(claudeMdPath)) {
121
+ try {
122
+ result.claudeMd = fs.readFileSync(claudeMdPath, 'utf8').slice(0, 2000);
123
+ } catch { /* ignore */ }
124
+ }
125
+
126
+ return result;
127
+ }
128
+
129
+ // ── Roster Generator ────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Generate an agent roster from scan results.
133
+ * @param {object} scanResult - Output from scanCodebase()
134
+ * @param {number} maxAgents - Maximum agents (default 5)
135
+ * @returns {Array<{name, role, keywords, filePatterns, isConductor}>}
136
+ */
137
+ export function generateRoster(scanResult, maxAgents = 5) {
138
+ const { languages, frameworks, infra, cloud, testFiles, fileStats } = scanResult;
139
+
140
+ // Domain detection: each entry is { name, role, keywords, filePatterns, score }
141
+ const candidates = [];
142
+
143
+ // ML/Data Science
144
+ const mlFrameworks = ['LightGBM', 'XGBoost', 'scikit-learn', 'PyTorch', 'TensorFlow', 'pandas', 'NumPy'];
145
+ const mlHits = frameworks.filter(f => mlFrameworks.includes(f));
146
+ if (mlHits.length > 0) {
147
+ candidates.push({
148
+ name: 'ml-eng',
149
+ role: `Machine learning: ${mlHits.join(', ')}`,
150
+ keywords: ['ranking', 'model', 'features', 'training', 'ndcg', 'lightgbm', 'xgboost', 'sklearn', 'torch', 'tensorflow'],
151
+ filePatterns: ['src/ranking/**', 'src/features/**', 'src/pipeline/**', 'models/**', 'ml/**'],
152
+ score: mlHits.length * 3,
153
+ });
154
+ }
155
+
156
+ // API / Backend
157
+ const apiFrameworks = ['FastAPI', 'Django', 'Flask', 'Express', 'Fastify', 'Hono', 'tRPC', 'Gin', 'Echo', 'Fiber', 'Actix', 'Axum', 'Rocket'];
158
+ const apiHits = frameworks.filter(f => apiFrameworks.includes(f));
159
+ if (apiHits.length > 0) {
160
+ candidates.push({
161
+ name: 'api-dev',
162
+ role: `API development: ${apiHits.join(', ')}`,
163
+ keywords: ['endpoint', 'api', 'route', 'schema', 'middleware', 'handler', 'controller'],
164
+ filePatterns: ['src/api/**', 'src/routes/**', 'api/**', 'server/**'],
165
+ score: apiHits.length * 3,
166
+ });
167
+ }
168
+
169
+ // Frontend
170
+ const feFrameworks = ['React', 'Next.js', 'Vue', 'Nuxt', 'Svelte', 'Angular', 'Tailwind CSS'];
171
+ const feHits = frameworks.filter(f => feFrameworks.includes(f));
172
+ if (feHits.length > 0) {
173
+ candidates.push({
174
+ name: 'frontend',
175
+ role: `Frontend: ${feHits.join(', ')}`,
176
+ keywords: ['component', 'page', 'css', 'layout', 'ui', 'styling', 'responsive'],
177
+ filePatterns: ['src/components/**', 'src/pages/**', 'app/**', 'components/**'],
178
+ score: feHits.length * 2,
179
+ });
180
+ }
181
+
182
+ // Data / Database
183
+ const dataFrameworks = ['SQLAlchemy', 'Alembic', 'Prisma', 'Pydantic'];
184
+ const dataHits = frameworks.filter(f => dataFrameworks.includes(f));
185
+ const hasSpanner = cloud.includes('GCP') && (frameworks.some(f => f === 'Pydantic') || infra.length > 0);
186
+ const hasMigrations = dirHasFiles(path.join(process.cwd(), 'migrations')) || dirHasFiles(path.join(process.cwd(), 'alembic'));
187
+ if (dataHits.length > 0 || hasSpanner || hasMigrations) {
188
+ candidates.push({
189
+ name: 'data-eng',
190
+ role: `Data infrastructure: ${[...dataHits, ...cloud].join(', ')}`,
191
+ keywords: ['database', 'migration', 'query', 'spanner', 'postgres', 'redis', 'schema', 'pipeline'],
192
+ filePatterns: ['migrations/**', 'src/db/**', 'src/data/**', 'alembic/**'],
193
+ score: (dataHits.length + (hasSpanner ? 2 : 0) + (hasMigrations ? 1 : 0)) * 2,
194
+ });
195
+ }
196
+
197
+ // Infrastructure / DevOps
198
+ if (infra.length > 0) {
199
+ candidates.push({
200
+ name: 'infra',
201
+ role: `Infrastructure: ${infra.join(', ')}`,
202
+ keywords: ['deploy', 'pipeline', 'docker', 'ci', 'infra', 'kubernetes', 'terraform', 'helm'],
203
+ filePatterns: ['k8s/**', 'terraform/**', 'infra/**', '.github/**', 'Dockerfile'],
204
+ score: infra.length * 2,
205
+ });
206
+ }
207
+
208
+ // QA / Testing
209
+ if (testFiles >= 2) {
210
+ candidates.push({
211
+ name: 'qa',
212
+ role: 'Testing, code review, CI/CD, security audits',
213
+ keywords: ['test', 'qa', 'review', 'security', 'ci', 'lint', 'coverage'],
214
+ filePatterns: ['tests/**', 'test/**', '__tests__/**', '*.test.*', '*.spec.*', '.github/**'],
215
+ score: Math.min(testFiles, 10),
216
+ });
217
+ }
218
+
219
+ // Documentation (only if substantial docs exist)
220
+ const hasDocsDir = dirHasFiles(path.join(process.cwd(), 'docs'));
221
+ const mdCount = fileStats['.md'] || 0;
222
+ if (hasDocsDir || mdCount > 5) {
223
+ candidates.push({
224
+ name: 'docs',
225
+ role: 'Documentation, knowledge curation, API docs',
226
+ keywords: ['documentation', 'readme', 'api-docs', 'changelog', 'guide'],
227
+ filePatterns: ['docs/**', '*.md'],
228
+ score: Math.min(mdCount, 3),
229
+ });
230
+ }
231
+
232
+ // Sort by score, take top (maxAgents - 1) to leave room for conductor
233
+ candidates.sort((a, b) => b.score - a.score);
234
+ const workerSlots = maxAgents - 1; // conductor takes one slot
235
+ const selected = candidates.slice(0, workerSlots);
236
+
237
+ // Always include QA if we have any test files and there's room
238
+ if (testFiles > 0 && !selected.find(c => c.name === 'qa') && selected.length < workerSlots) {
239
+ selected.push({
240
+ name: 'qa',
241
+ role: 'Testing, code review, CI/CD',
242
+ keywords: ['test', 'qa', 'review', 'security', 'ci', 'lint'],
243
+ filePatterns: ['tests/**', 'test/**'],
244
+ score: 1,
245
+ });
246
+ }
247
+
248
+ // Build final roster with conductor first
249
+ const roster = [
250
+ {
251
+ name: 'conductor',
252
+ role: 'Orchestration, planning, task decomposition, sprint management',
253
+ keywords: [],
254
+ filePatterns: [],
255
+ isConductor: true,
256
+ },
257
+ ...selected.map(({ score, ...agent }) => ({ ...agent, isConductor: false })),
258
+ ];
259
+
260
+ return roster;
261
+ }
262
+
263
+ // ── Helpers ─────────────────────────────────────────────────────────
264
+
265
+ function readDependencies(projectRoot) {
266
+ const deps = [];
267
+
268
+ // package.json
269
+ try {
270
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
271
+ deps.push(...Object.keys(pkg.dependencies || {}));
272
+ deps.push(...Object.keys(pkg.devDependencies || {}));
273
+ } catch { /* no package.json */ }
274
+
275
+ // pyproject.toml (handle both PEP 621 and Poetry formats)
276
+ try {
277
+ const toml = fs.readFileSync(path.join(projectRoot, 'pyproject.toml'), 'utf8');
278
+
279
+ // PEP 621: dependencies = ["package>=1.0"]
280
+ const depLines = toml.match(/(?:dependencies|requires)\s*=\s*\[([^\]]*)\]/gs);
281
+ if (depLines) {
282
+ for (const block of depLines) {
283
+ const matches = block.match(/"([^"]+)"/g) || [];
284
+ for (const m of matches) {
285
+ const name = m.replace(/"/g, '').split(/[>=<!\s]/)[0];
286
+ deps.push(name);
287
+ }
288
+ }
289
+ }
290
+
291
+ // Poetry: [tool.poetry.dependencies] section (key = "version" pairs)
292
+ const poetrySection = toml.match(/\[tool\.poetry\.dependencies\]([\s\S]*?)(?:\n\[|$)/);
293
+ if (poetrySection) {
294
+ for (const line of poetrySection[1].split('\n')) {
295
+ const match = line.match(/^(\S+)\s*=/);
296
+ if (match && match[1] !== 'python') {
297
+ deps.push(match[1]);
298
+ }
299
+ }
300
+ }
301
+
302
+ // Poetry group deps: [tool.poetry.group.dev.dependencies]
303
+ const groupSections = toml.matchAll(/\[tool\.poetry\.group\.\w+\.dependencies\]([\s\S]*?)(?:\n\[|$)/g);
304
+ for (const section of groupSections) {
305
+ for (const line of section[1].split('\n')) {
306
+ const match = line.match(/^(\S+)\s*=/);
307
+ if (match && match[1] !== 'python') {
308
+ deps.push(match[1]);
309
+ }
310
+ }
311
+ }
312
+ } catch { /* no pyproject.toml */ }
313
+
314
+ // requirements.txt
315
+ try {
316
+ const req = fs.readFileSync(path.join(projectRoot, 'requirements.txt'), 'utf8');
317
+ for (const line of req.split('\n')) {
318
+ const trimmed = line.trim();
319
+ if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('-')) {
320
+ deps.push(trimmed.split(/[>=<!\s]/)[0]);
321
+ }
322
+ }
323
+ } catch { /* no requirements.txt */ }
324
+
325
+ // go.mod
326
+ try {
327
+ const gomod = fs.readFileSync(path.join(projectRoot, 'go.mod'), 'utf8');
328
+ const requireBlock = gomod.match(/require\s*\(([\s\S]*?)\)/);
329
+ if (requireBlock) {
330
+ for (const line of requireBlock[1].split('\n')) {
331
+ const parts = line.trim().split(/\s+/);
332
+ if (parts[0] && !parts[0].startsWith('//')) deps.push(parts[0]);
333
+ }
334
+ }
335
+ } catch { /* no go.mod */ }
336
+
337
+ return deps;
338
+ }
339
+
340
+ function readConfigFiles(projectRoot) {
341
+ const configFiles = [
342
+ 'fleet.config.json', '.env', '.env.example', '.env.production',
343
+ 'docker-compose.yml', 'docker-compose.yaml',
344
+ 'cloudbuild.yaml', 'app.yaml', 'serverless.yml',
345
+ ];
346
+ let text = '';
347
+ for (const file of configFiles) {
348
+ try {
349
+ text += fs.readFileSync(path.join(projectRoot, file), 'utf8') + '\n';
350
+ } catch { /* ignore */ }
351
+ }
352
+ return text.toLowerCase();
353
+ }
354
+
355
+ function dirHasFiles(dirPath) {
356
+ try {
357
+ const entries = fs.readdirSync(dirPath);
358
+ return entries.length > 0;
359
+ } catch {
360
+ return false;
361
+ }
362
+ }
363
+
364
+ function countFiles(projectRoot) {
365
+ const stats = {};
366
+ const ignore = ['node_modules', '.git', '.fleet', '__pycache__', '.venv', 'venv', 'dist', 'build', '.next'];
367
+
368
+ function walk(dir) {
369
+ let entries;
370
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
371
+
372
+ for (const entry of entries) {
373
+ if (ignore.includes(entry.name)) continue;
374
+
375
+ const fullPath = path.join(dir, entry.name);
376
+ if (entry.isDirectory()) {
377
+ walk(fullPath);
378
+ } else if (entry.isFile()) {
379
+ const ext = path.extname(entry.name).toLowerCase() || '(no ext)';
380
+ stats[ext] = (stats[ext] || 0) + 1;
381
+ }
382
+ }
383
+ }
384
+
385
+ walk(projectRoot);
386
+ return stats;
387
+ }
388
+
389
+ function countTestFiles(projectRoot) {
390
+ let count = 0;
391
+ const testDirs = ['tests', 'test', '__tests__', 'spec'];
392
+ const ignore = ['node_modules', '.git', '.fleet', '__pycache__', '.venv', 'venv'];
393
+
394
+ // Count files in test directories
395
+ for (const dir of testDirs) {
396
+ const testDir = path.join(projectRoot, dir);
397
+ try {
398
+ const entries = fs.readdirSync(testDir, { withFileTypes: true });
399
+ count += entries.filter(e => e.isFile()).length;
400
+ } catch { /* no test dir */ }
401
+ }
402
+
403
+ // Also count *.test.* and *.spec.* files in src/
404
+ function walkForTests(dir) {
405
+ let entries;
406
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
407
+
408
+ for (const entry of entries) {
409
+ if (ignore.includes(entry.name)) continue;
410
+ const fullPath = path.join(dir, entry.name);
411
+ if (entry.isDirectory()) {
412
+ walkForTests(fullPath);
413
+ } else if (entry.isFile() && (/\.test\./.test(entry.name) || /\.spec\./.test(entry.name))) {
414
+ count++;
415
+ }
416
+ }
417
+ }
418
+
419
+ walkForTests(path.join(projectRoot, 'src'));
420
+ return count;
421
+ }
package/src/init.js ADDED
@@ -0,0 +1,228 @@
1
+ // init.js — First-use wizard: scan codebase, generate agents, create workspace.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import readline from 'node:readline';
7
+ import { scanCodebase, generateRoster } from './generate.js';
8
+ import { setupAgentWorkspaces } from './setup-agents.js';
9
+
10
+ export default async function init(args) {
11
+ const projectRoot = process.cwd();
12
+ console.log('\nFleet Setup');
13
+ console.log(` Project: ${projectRoot}\n`);
14
+
15
+ // Check if already initialized
16
+ const configPath = path.join(projectRoot, 'fleet.config.json');
17
+ if (fs.existsSync(configPath)) {
18
+ const existing = JSON.parse(fs.readFileSync(configPath, 'utf8'));
19
+ console.log(` Already initialized (team: ${existing.teamId})`);
20
+ console.log(' Re-running will update config without destroying sessions.\n');
21
+ }
22
+
23
+ // 1. Scan codebase
24
+ console.log(' Scanning codebase...');
25
+ const scan = scanCodebase(projectRoot);
26
+
27
+ if (scan.languages.length > 0) {
28
+ console.log(` Languages: ${scan.languages.join(', ')}`);
29
+ }
30
+ if (scan.frameworks.length > 0) {
31
+ console.log(` Frameworks: ${scan.frameworks.join(', ')}`);
32
+ }
33
+ if (scan.infra.length > 0) {
34
+ console.log(` Infrastructure: ${scan.infra.join(', ')}`);
35
+ }
36
+ if (scan.cloud.length > 0) {
37
+ console.log(` Cloud: ${scan.cloud.join(', ')}`);
38
+ }
39
+
40
+ const totalFiles = Object.values(scan.fileStats).reduce((a, b) => a + b, 0);
41
+ const topExts = Object.entries(scan.fileStats)
42
+ .sort((a, b) => b[1] - a[1])
43
+ .slice(0, 5)
44
+ .map(([ext, count]) => `${ext}: ${count}`)
45
+ .join(', ');
46
+ console.log(` Files: ${totalFiles} (${topExts})`);
47
+ if (scan.testFiles > 0) {
48
+ console.log(` Test files: ${scan.testFiles}`);
49
+ }
50
+ console.log('');
51
+
52
+ // 2. Generate roster
53
+ const roster = generateRoster(scan);
54
+ console.log(` Recommended agents (${roster.length}):`);
55
+ for (const agent of roster) {
56
+ const type = agent.isConductor ? '(conductor)' : '';
57
+ console.log(` ${agent.name.padEnd(12)} -- ${agent.role} ${type}`);
58
+ }
59
+ console.log('');
60
+
61
+ // 3. Get Supabase config
62
+ let supabaseUrl = process.env.FLEET_SUPABASE_URL || '';
63
+ let supabaseKey = process.env.FLEET_SUPABASE_KEY || '';
64
+ let teamId = process.env.FLEET_TEAM_ID || '';
65
+
66
+ // Auto-suggest team ID
67
+ const username = os.userInfo().username || 'user';
68
+ const dirname = path.basename(projectRoot);
69
+ const suggestedTeamId = `${username}-${dirname}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');
70
+
71
+ // Non-interactive mode: all env vars set
72
+ const nonInteractive = supabaseUrl && supabaseKey;
73
+
74
+ if (nonInteractive) {
75
+ if (!teamId) teamId = suggestedTeamId;
76
+ console.log(` Supabase URL: ${supabaseUrl} [from env]`);
77
+ console.log(` Supabase key: ****${supabaseKey.slice(-4)} [from env]`);
78
+ console.log(` Team ID: ${teamId}${process.env.FLEET_TEAM_ID ? ' [from env]' : ' [auto]'}`);
79
+ } else {
80
+ // Interactive prompts
81
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
82
+ const ask = (q, dflt) => new Promise(resolve => {
83
+ const prompt = dflt ? `${q} [${dflt}]: ` : `${q}: `;
84
+ rl.question(` ${prompt}`, answer => resolve(answer.trim() || dflt || ''));
85
+ });
86
+
87
+ if (!supabaseUrl) {
88
+ supabaseUrl = await ask('Supabase URL', 'https://svfqcnfxcbwjwbugiiyv.supabase.co');
89
+ } else {
90
+ console.log(` Supabase URL: ${supabaseUrl} [from env]`);
91
+ }
92
+
93
+ if (!supabaseKey) {
94
+ supabaseKey = await ask('Supabase key (anon)', '');
95
+ if (!supabaseKey) {
96
+ console.error('\n Error: Supabase key is required. Set FLEET_SUPABASE_KEY or provide it here.');
97
+ rl.close();
98
+ process.exit(1);
99
+ }
100
+ } else {
101
+ console.log(` Supabase key: ****${supabaseKey.slice(-4)} [from env]`);
102
+ }
103
+
104
+ if (!teamId) {
105
+ teamId = await ask('Team ID', suggestedTeamId);
106
+ }
107
+
108
+ rl.close();
109
+ }
110
+ console.log('');
111
+
112
+ // 4. Create workspace
113
+ console.log(' Creating workspace...');
114
+
115
+ // Write fleet.config.json
116
+ const config = {
117
+ version: 1,
118
+ teamId,
119
+ supabase: {
120
+ url: supabaseUrl,
121
+ key: supabaseKey,
122
+ },
123
+ execution: {
124
+ mode: 'on-demand',
125
+ maxConcurrent: 3,
126
+ timeouts: {
127
+ task: 1800000,
128
+ review: 300000,
129
+ watch: 180000,
130
+ },
131
+ },
132
+ agents: roster,
133
+ daemon: {
134
+ loopIntervalMs: 5000,
135
+ cron: {
136
+ healthCheckMinutes: 10,
137
+ synthesisHours: 2,
138
+ },
139
+ },
140
+ };
141
+
142
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
143
+ console.log(' fleet.config.json');
144
+
145
+ // Setup agent workspaces
146
+ setupAgentWorkspaces(projectRoot, roster);
147
+ const agentNames = roster.map(a => a.name).join(', ');
148
+ console.log(` .fleet/agents/{${agentNames}}/`);
149
+ console.log(' .fleet/sessions.json');
150
+
151
+ // Write .mcp.json
152
+ const mcpJsonPath = path.join(projectRoot, '.mcp.json');
153
+ const mcpConfig = {
154
+ mcpServers: {
155
+ fleet: {
156
+ type: 'stdio',
157
+ command: 'npx',
158
+ args: ['-y', '@grantx/fleet-mcp', '--config', './fleet.config.json'],
159
+ },
160
+ },
161
+ };
162
+
163
+ // Merge with existing .mcp.json if present
164
+ if (fs.existsSync(mcpJsonPath)) {
165
+ try {
166
+ const existing = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf8'));
167
+ existing.mcpServers = existing.mcpServers || {};
168
+ existing.mcpServers.fleet = mcpConfig.mcpServers.fleet;
169
+ fs.writeFileSync(mcpJsonPath, JSON.stringify(existing, null, 2));
170
+ } catch {
171
+ fs.writeFileSync(mcpJsonPath, JSON.stringify(mcpConfig, null, 2));
172
+ }
173
+ } else {
174
+ fs.writeFileSync(mcpJsonPath, JSON.stringify(mcpConfig, null, 2));
175
+ }
176
+ console.log(' .mcp.json (Claude Code MCP registration)');
177
+
178
+ // Create .claude/commands/fleet.md slash command
179
+ const commandsDir = path.join(projectRoot, '.claude', 'commands');
180
+ fs.mkdirSync(commandsDir, { recursive: true });
181
+ const fleetCommandPath = path.join(commandsDir, 'fleet.md');
182
+ if (!fs.existsSync(fleetCommandPath)) {
183
+ fs.writeFileSync(fleetCommandPath, FLEET_COMMAND_TEMPLATE);
184
+ console.log(' .claude/commands/fleet.md');
185
+ }
186
+
187
+ // Update .gitignore
188
+ updateGitignore(projectRoot);
189
+
190
+ console.log('');
191
+ console.log(' Done. Open Claude Code and try: /fleet status');
192
+ console.log('');
193
+ }
194
+
195
+ function updateGitignore(projectRoot) {
196
+ const gitignorePath = path.join(projectRoot, '.gitignore');
197
+ let content = '';
198
+ try { content = fs.readFileSync(gitignorePath, 'utf8'); } catch { /* new file */ }
199
+
200
+ const entries = ['.fleet/', 'fleet.config.json'];
201
+ let modified = false;
202
+
203
+ for (const entry of entries) {
204
+ if (!content.includes(entry)) {
205
+ content += `\n${entry}`;
206
+ modified = true;
207
+ }
208
+ }
209
+
210
+ if (modified) {
211
+ fs.writeFileSync(gitignorePath, content.trimEnd() + '\n');
212
+ console.log(' .gitignore (updated)');
213
+ }
214
+ }
215
+
216
+ const FLEET_COMMAND_TEMPLATE = `# /fleet — Fleet Management
217
+
218
+ Usage: /fleet <subcommand>
219
+
220
+ Subcommands:
221
+ status — Show fleet dashboard (agents, tasks, sprint progress)
222
+ dispatch — Dispatch a task to an agent
223
+ plan — Decompose a goal into a sprint
224
+ review — Review completed work
225
+ agents — List agents and their status
226
+
227
+ This command uses the fleet MCP server tools. Make sure the MCP server is running.
228
+ `;
@@ -0,0 +1,115 @@
1
+ // setup-agents.js — Create agent HOME directories and copy credentials.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { randomUUID } from 'node:crypto';
7
+
8
+ /**
9
+ * Create agent workspace directories and copy/symlink credentials.
10
+ * @param {string} projectRoot - Project root directory
11
+ * @param {Array} roster - Agent roster from generateRoster()
12
+ */
13
+ export function setupAgentWorkspaces(projectRoot, roster) {
14
+ const fleetDir = path.join(projectRoot, '.fleet');
15
+ const agentsDir = path.join(fleetDir, 'agents');
16
+ const logsDir = path.join(fleetDir, 'logs');
17
+
18
+ // Create directories
19
+ fs.mkdirSync(agentsDir, { recursive: true });
20
+ fs.mkdirSync(logsDir, { recursive: true });
21
+
22
+ // Create per-agent HOME directories + credentials
23
+ for (const agent of roster) {
24
+ const agentDir = path.join(agentsDir, agent.name);
25
+ const agentClaudeDir = path.join(agentDir, '.claude');
26
+ fs.mkdirSync(agentClaudeDir, { recursive: true });
27
+
28
+ // Copy/symlink credentials
29
+ setupCredentials(agentClaudeDir);
30
+
31
+ // Write per-agent CLAUDE.md
32
+ const claudeMd = generateAgentClaudeMd(agent, projectRoot);
33
+ fs.writeFileSync(path.join(agentDir, 'CLAUDE.md'), claudeMd);
34
+ }
35
+
36
+ // Initialize sessions.json
37
+ const sessionsFile = path.join(fleetDir, 'sessions.json');
38
+ if (!fs.existsSync(sessionsFile)) {
39
+ const sessions = {};
40
+ for (const agent of roster) {
41
+ sessions[agent.name] = { id: randomUUID(), initialized: false };
42
+ }
43
+ fs.writeFileSync(sessionsFile, JSON.stringify(sessions, null, 2));
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Setup credentials for an agent directory.
49
+ * Symlinks on Unix, copies on Windows.
50
+ */
51
+ function setupCredentials(agentClaudeDir) {
52
+ const userCredsPath = path.join(os.homedir(), '.claude', '.credentials.json');
53
+ const agentCredsPath = path.join(agentClaudeDir, '.credentials.json');
54
+
55
+ if (!fs.existsSync(userCredsPath)) {
56
+ // No credentials file — skip silently. Agent will need to authenticate.
57
+ return;
58
+ }
59
+
60
+ // Remove existing if present
61
+ try { fs.unlinkSync(agentCredsPath); } catch { /* ignore */ }
62
+
63
+ const isWindows = process.platform === 'win32';
64
+ if (isWindows) {
65
+ fs.copyFileSync(userCredsPath, agentCredsPath);
66
+ } else {
67
+ try {
68
+ fs.symlinkSync(userCredsPath, agentCredsPath);
69
+ } catch {
70
+ // Fallback to copy if symlink fails
71
+ fs.copyFileSync(userCredsPath, agentCredsPath);
72
+ }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Generate CLAUDE.md for an agent with role context.
78
+ */
79
+ function generateAgentClaudeMd(agent, projectRoot) {
80
+ const lines = [];
81
+ lines.push(`# ${agent.name} — Fleet Agent`);
82
+ lines.push('');
83
+ lines.push(`## Role`);
84
+ lines.push(agent.role);
85
+ lines.push('');
86
+
87
+ if (agent.isConductor) {
88
+ lines.push('## Conductor Responsibilities');
89
+ lines.push('- Orchestrate task execution across worker agents');
90
+ lines.push('- Decompose goals into actionable tasks');
91
+ lines.push('- Monitor progress and resolve blockers');
92
+ lines.push('- Make dispatch decisions based on agent specialties and availability');
93
+ } else {
94
+ if (agent.keywords?.length > 0) {
95
+ lines.push('## Specialty Keywords');
96
+ lines.push(agent.keywords.join(', '));
97
+ lines.push('');
98
+ }
99
+ if (agent.filePatterns?.length > 0) {
100
+ lines.push('## Owned File Patterns');
101
+ lines.push(agent.filePatterns.join(', '));
102
+ lines.push('');
103
+ }
104
+ }
105
+
106
+ lines.push('');
107
+ lines.push('## Working Rules');
108
+ lines.push('- Always search the codebase before making changes');
109
+ lines.push('- Follow existing code patterns and conventions');
110
+ lines.push('- Run tests after changes if a test suite exists');
111
+ lines.push('- Output a clear summary of what you did when completing a task');
112
+ lines.push('- Do not modify files outside your owned patterns unless necessary');
113
+
114
+ return lines.join('\n');
115
+ }
package/src/start.js ADDED
@@ -0,0 +1,65 @@
1
+ // start.js — Launch fleet daemon in background.
2
+ // Daemon runs ConductorLoop for autonomous task dispatch.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+
8
+ export default async function start(args) {
9
+ const projectRoot = process.cwd();
10
+ const configPath = path.join(projectRoot, 'fleet.config.json');
11
+
12
+ if (!fs.existsSync(configPath)) {
13
+ console.error('Error: fleet.config.json not found. Run `fleet init` first.');
14
+ process.exit(1);
15
+ }
16
+
17
+ const pidFile = path.join(projectRoot, '.fleet', 'daemon.pid');
18
+ const logFile = path.join(projectRoot, '.fleet', 'logs', 'daemon.log');
19
+
20
+ // Check if already running
21
+ if (fs.existsSync(pidFile)) {
22
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim());
23
+ if (isProcessRunning(pid)) {
24
+ console.log(`Fleet daemon already running (PID ${pid}).`);
25
+ console.log(`Logs: ${logFile}`);
26
+ process.exit(0);
27
+ }
28
+ // Stale PID file
29
+ fs.unlinkSync(pidFile);
30
+ }
31
+
32
+ // Ensure log directory
33
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
34
+
35
+ // Spawn daemon process
36
+ const daemonScript = path.join(import.meta.dirname, 'daemon.js');
37
+ const logFd = fs.openSync(logFile, 'a');
38
+
39
+ const child = spawn(process.execPath, [daemonScript, '--config', configPath], {
40
+ cwd: projectRoot,
41
+ detached: true,
42
+ stdio: ['ignore', logFd, logFd],
43
+ env: { ...process.env, FLEET_LOG_DIR: path.join(projectRoot, '.fleet', 'logs') },
44
+ });
45
+
46
+ child.unref();
47
+ fs.closeSync(logFd);
48
+
49
+ // Write PID file
50
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true });
51
+ fs.writeFileSync(pidFile, String(child.pid));
52
+
53
+ console.log(`Fleet daemon started (PID ${child.pid}).`);
54
+ console.log(`Logs: ${logFile}`);
55
+ console.log('Run `fleet stop` to shut down.');
56
+ }
57
+
58
+ function isProcessRunning(pid) {
59
+ try {
60
+ process.kill(pid, 0);
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
package/src/stop.js ADDED
@@ -0,0 +1,60 @@
1
+ // stop.js — Stop fleet daemon gracefully.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ export default async function stop(args) {
7
+ const projectRoot = process.cwd();
8
+ const pidFile = path.join(projectRoot, '.fleet', 'daemon.pid');
9
+
10
+ if (!fs.existsSync(pidFile)) {
11
+ console.log('Fleet daemon is not running (no PID file).');
12
+ process.exit(0);
13
+ }
14
+
15
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim());
16
+
17
+ if (!isProcessRunning(pid)) {
18
+ console.log(`Fleet daemon (PID ${pid}) is not running. Cleaning up PID file.`);
19
+ fs.unlinkSync(pidFile);
20
+ process.exit(0);
21
+ }
22
+
23
+ console.log(`Stopping fleet daemon (PID ${pid})...`);
24
+
25
+ try {
26
+ process.kill(pid, 'SIGTERM');
27
+ } catch (err) {
28
+ console.error(`Failed to send SIGTERM: ${err.message}`);
29
+ process.exit(1);
30
+ }
31
+
32
+ // Wait for process to exit (up to 10s)
33
+ const deadline = Date.now() + 10000;
34
+ while (Date.now() < deadline) {
35
+ if (!isProcessRunning(pid)) {
36
+ fs.unlinkSync(pidFile);
37
+ console.log('Fleet daemon stopped.');
38
+ return;
39
+ }
40
+ await new Promise(r => setTimeout(r, 500));
41
+ }
42
+
43
+ // Force kill
44
+ console.log('Daemon did not exit gracefully, sending SIGKILL...');
45
+ try {
46
+ process.kill(pid, 'SIGKILL');
47
+ } catch { /* ignore */ }
48
+
49
+ try { fs.unlinkSync(pidFile); } catch { /* ignore */ }
50
+ console.log('Fleet daemon killed.');
51
+ }
52
+
53
+ function isProcessRunning(pid) {
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }