@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 +15 -0
- package/src/agent-cmd.js +91 -0
- package/src/cli.js +51 -0
- package/src/daemon.js +31 -0
- package/src/generate.js +421 -0
- package/src/init.js +228 -0
- package/src/setup-agents.js +115 -0
- package/src/start.js +65 -0
- package/src/stop.js +60 -0
package/package.json
ADDED
package/src/agent-cmd.js
ADDED
|
@@ -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'));
|
package/src/generate.js
ADDED
|
@@ -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
|
+
}
|