@grantx/fleet-core 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.
@@ -0,0 +1,102 @@
1
+ // platform.js — Cross-platform abstractions for fleet plugin.
2
+ // Replaces all hardcoded /tmp, /opt/homebrew, /Users/clawcob paths.
3
+
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import fs from 'node:fs';
7
+
8
+ export const isWindows = process.platform === 'win32';
9
+ export const isMac = process.platform === 'darwin';
10
+ export const isLinux = process.platform === 'linux';
11
+
12
+ // Lockfile directory: os.tmpdir()/fleet on Windows, /tmp on Unix
13
+ export const lockDir = isWindows
14
+ ? path.join(os.tmpdir(), 'fleet')
15
+ : '/tmp';
16
+
17
+ /**
18
+ * Ensure the lockfile directory exists.
19
+ */
20
+ export function ensureLockDir() {
21
+ fs.mkdirSync(lockDir, { recursive: true });
22
+ }
23
+
24
+ /**
25
+ * Build environment variables for a spawned agent process.
26
+ * Inherits the user's PATH (no hardcoded /opt/homebrew).
27
+ */
28
+ export function buildAgentEnv(agentName, agentHome, teamId, extra = {}) {
29
+ const env = {
30
+ HOME: agentHome,
31
+ FLEET_AGENT_ID: agentName,
32
+ FLEET_TEAM_ID: teamId,
33
+ PATH: process.env.PATH,
34
+ CLOUDSDK_CONFIG: process.env.CLOUDSDK_CONFIG || path.join(os.homedir(), '.config', 'gcloud'),
35
+ ...extra,
36
+ };
37
+
38
+ // Windows uses USERPROFILE as HOME equivalent
39
+ if (isWindows) {
40
+ env.USERPROFILE = agentHome;
41
+ }
42
+
43
+ return env;
44
+ }
45
+
46
+ /**
47
+ * Set up Claude credentials for an agent.
48
+ * Unix: symlink to user's credentials (auto-refreshes).
49
+ * Windows: copy (symlinks require admin privileges).
50
+ */
51
+ export function setupCredentials(agentHome) {
52
+ const userCreds = path.join(os.homedir(), '.claude', '.credentials.json');
53
+ const agentClaudeDir = path.join(agentHome, '.claude');
54
+ const agentCreds = path.join(agentClaudeDir, '.credentials.json');
55
+
56
+ if (!fs.existsSync(userCreds)) {
57
+ throw new Error(`Claude credentials not found at ${userCreds}. Run 'claude' first to authenticate.`);
58
+ }
59
+
60
+ fs.mkdirSync(agentClaudeDir, { recursive: true });
61
+
62
+ // Remove existing (stale copy or broken symlink)
63
+ try { fs.unlinkSync(agentCreds); } catch { /* doesn't exist */ }
64
+
65
+ if (isWindows) {
66
+ fs.copyFileSync(userCreds, agentCreds);
67
+ } else {
68
+ fs.symlinkSync(userCreds, agentCreds);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Refresh credentials for all agents (Windows only — copies are stale).
74
+ * No-op on Unix (symlinks auto-refresh).
75
+ */
76
+ export function refreshCredentials(fleetDir, agentNames) {
77
+ if (!isWindows) return;
78
+
79
+ const userCreds = path.join(os.homedir(), '.claude', '.credentials.json');
80
+ if (!fs.existsSync(userCreds)) return;
81
+
82
+ for (const name of agentNames) {
83
+ const agentCreds = path.join(fleetDir, 'agents', name, '.claude', '.credentials.json');
84
+ try {
85
+ fs.copyFileSync(userCreds, agentCreds);
86
+ } catch { /* agent dir may not exist yet */ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Resolve the fleet working directory (.fleet/) from a project root.
92
+ */
93
+ export function fleetDir(projectRoot) {
94
+ return path.join(projectRoot, '.fleet');
95
+ }
96
+
97
+ /**
98
+ * Resolve an agent's HOME directory.
99
+ */
100
+ export function agentHome(projectRoot, agentName) {
101
+ return path.join(projectRoot, '.fleet', 'agents', agentName);
102
+ }
@@ -0,0 +1,153 @@
1
+ // smart-dispatcher.js — Heuristic fast-path dispatch.
2
+ // Handles the common case (1 pending task + idle worker) in <100ms
3
+ // without invoking conductor AI. Falls through for complex decisions.
4
+ // Keywords loaded from fleet.config.json, not hardcoded.
5
+
6
+ import { log } from './logger.js';
7
+ import { dispatchAgent } from './fleet-utils.js';
8
+ import { getWorkerAgents } from './config.js';
9
+
10
+ export class SmartDispatcher {
11
+ constructor(agentPool, supabase, config) {
12
+ this.agentPool = agentPool;
13
+ this.supabase = supabase;
14
+ this.config = config;
15
+ this.stats = { fastPath: 0, fallthrough: 0 };
16
+
17
+ // Build keyword map from config
18
+ this.workerAgents = getWorkerAgents(config).map(a => a.name);
19
+ this.specialtyKeywords = {};
20
+ for (const agent of getWorkerAgents(config)) {
21
+ if (agent.keywords?.length > 0) {
22
+ this.specialtyKeywords[agent.name] = agent.keywords;
23
+ }
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Try to handle events via fast-path. Returns true if handled, false if conductor AI needed.
29
+ */
30
+ async tryFastPath(events, state) {
31
+ if (!this._isSimpleScenario(events, state)) {
32
+ this.stats.fallthrough++;
33
+ return false;
34
+ }
35
+
36
+ const pendingTasks = state.pendingTasks || [];
37
+ if (pendingTasks.length === 0) {
38
+ log.info('smart-dispatch', 'Fast-path: no pending tasks, skipping cycle');
39
+ this.stats.fastPath++;
40
+ return true;
41
+ }
42
+
43
+ const dispatchable = await this._filterByDeps(pendingTasks);
44
+ if (dispatchable.length === 0 && pendingTasks.length > 0) {
45
+ log.info('smart-dispatch', `Fast-path: ${pendingTasks.length} pending but all blocked by deps`);
46
+ this.stats.fastPath++;
47
+ return true;
48
+ }
49
+
50
+ let dispatched = 0;
51
+ for (const task of dispatchable) {
52
+ const agent = this._pickAgent(task);
53
+ if (!agent) continue;
54
+
55
+ log.info('smart-dispatch', `Fast-path: dispatching ${agent} -> "${task.title}" (${task.id.slice(0, 8)})`);
56
+ await dispatchAgent(
57
+ { agent, task_id: task.id, reason: 'smart-dispatch: fast-path' },
58
+ this.agentPool, this.supabase, this.config
59
+ );
60
+ dispatched++;
61
+ break; // One per cycle
62
+ }
63
+
64
+ if (dispatched === 0 && pendingTasks.length > 0) {
65
+ log.info('smart-dispatch', `Fast-path: ${pendingTasks.length} pending but no idle workers`);
66
+ this.stats.fallthrough++;
67
+ return false;
68
+ }
69
+
70
+ this.stats.fastPath++;
71
+ return true;
72
+ }
73
+
74
+ /**
75
+ * Chain-dispatch: after a task completion, immediately dispatch next pending for same agent.
76
+ */
77
+ async tryChainDispatch(completedAgent, state) {
78
+ if (this.agentPool.running.has(completedAgent)) return false;
79
+
80
+ const pendingForAgent = (state.pendingTasks || []).filter(t => t.to_agent === completedAgent);
81
+ if (pendingForAgent.length === 0) return false;
82
+
83
+ const dispatchable = await this._filterByDeps(pendingForAgent);
84
+ if (dispatchable.length === 0) return false;
85
+
86
+ const next = dispatchable[0];
87
+ log.info('smart-dispatch', `Chain-dispatch: ${completedAgent} -> "${next.title}"`);
88
+ await dispatchAgent(
89
+ { agent: completedAgent, task_id: next.id, reason: 'smart-dispatch: chain after completion' },
90
+ this.agentPool, this.supabase, this.config
91
+ );
92
+
93
+ this.stats.fastPath++;
94
+ return true;
95
+ }
96
+
97
+ _isSimpleScenario(events, state) {
98
+ const complexTypes = ['task_flagged', 'task_failed', 'agent_health', 'sprint_gate'];
99
+ if (events.some(e => complexTypes.includes(e.type))) return false;
100
+ if (state.blockedTasks?.length > 0) return false;
101
+ if ((state.pendingTasks?.length || 0) > 5) return false;
102
+ if (!events.every(e => e.type === 'dispatch_tick' || e.type === 'task_completed')) return false;
103
+ return true;
104
+ }
105
+
106
+ async _filterByDeps(tasks) {
107
+ const tasksWithDeps = tasks.filter(t => t.depends_on?.length > 0);
108
+ if (tasksWithDeps.length === 0) return tasks;
109
+
110
+ const allDepIds = [...new Set(tasksWithDeps.flatMap(t => t.depends_on))];
111
+ try {
112
+ const depTasks = await this.supabase.getUnscoped(
113
+ `task_queue?id=in.(${allDepIds.join(',')})&select=id,status`
114
+ );
115
+ const depStatus = new Map(depTasks.map(t => [t.id, t.status]));
116
+
117
+ return tasks.filter(task => {
118
+ if (!task.depends_on?.length) return true;
119
+ return task.depends_on.every(depId => depStatus.get(depId) === 'completed');
120
+ });
121
+ } catch (err) {
122
+ log.warn('smart-dispatch', `Dep check failed, allowing all: ${err.message}`);
123
+ return tasks;
124
+ }
125
+ }
126
+
127
+ _pickAgent(task) {
128
+ // Explicit assignment
129
+ if (task.to_agent && this.workerAgents.includes(task.to_agent)) {
130
+ if (!this.agentPool.running.has(task.to_agent)) return task.to_agent;
131
+ return null;
132
+ }
133
+
134
+ // Keyword match from config
135
+ const taskText = `${task.title} ${task.description || ''}`.toLowerCase();
136
+ for (const [agent, keywords] of Object.entries(this.specialtyKeywords)) {
137
+ if (keywords.some(kw => taskText.includes(kw)) && !this.agentPool.running.has(agent)) {
138
+ return agent;
139
+ }
140
+ }
141
+
142
+ // First idle worker
143
+ for (const agent of this.workerAgents) {
144
+ if (!this.agentPool.running.has(agent)) return agent;
145
+ }
146
+
147
+ return null;
148
+ }
149
+
150
+ getStats() {
151
+ return { ...this.stats };
152
+ }
153
+ }
@@ -0,0 +1,87 @@
1
+ // supabase-client.js — Team-scoped Supabase REST wrapper.
2
+ // All queries automatically filtered by team_id.
3
+
4
+ import { log } from './logger.js';
5
+
6
+ export class SupabaseClient {
7
+ /**
8
+ * @param {string} url - Supabase project URL
9
+ * @param {string} key - Supabase API key (anon or service_role)
10
+ * @param {string} teamId - Team ID for scoping queries
11
+ */
12
+ constructor(url, key, teamId) {
13
+ this.url = url.replace(/\/$/, '');
14
+ this.key = key;
15
+ this.teamId = teamId;
16
+ }
17
+
18
+ /**
19
+ * GET with team_id filter auto-appended.
20
+ */
21
+ async get(path) {
22
+ const sep = path.includes('?') ? '&' : '?';
23
+ const scopedPath = `${path}${sep}team_id=eq.${this.teamId}`;
24
+ return this._request('GET', scopedPath);
25
+ }
26
+
27
+ /**
28
+ * GET without team_id filter (for cross-team queries like dep checking).
29
+ */
30
+ async getUnscoped(path) {
31
+ return this._request('GET', path);
32
+ }
33
+
34
+ /**
35
+ * POST with team_id auto-injected into body.
36
+ */
37
+ async post(path, body) {
38
+ const scopedBody = { ...body, team_id: this.teamId };
39
+ return this._request('POST', path, scopedBody);
40
+ }
41
+
42
+ /**
43
+ * PATCH with team_id filter on path and in body.
44
+ */
45
+ async patch(path, body) {
46
+ const sep = path.includes('?') ? '&' : '?';
47
+ const scopedPath = `${path}${sep}team_id=eq.${this.teamId}`;
48
+ const scopedBody = { ...body, team_id: this.teamId };
49
+ return this._request('PATCH', scopedPath, scopedBody);
50
+ }
51
+
52
+ async _request(method, path, body) {
53
+ const opts = {
54
+ method,
55
+ headers: {
56
+ 'apikey': this.key,
57
+ 'Authorization': `Bearer ${this.key}`,
58
+ 'Content-Type': 'application/json',
59
+ 'Prefer': 'return=representation',
60
+ 'x-team-id': this.teamId,
61
+ },
62
+ };
63
+
64
+ if (body && (method === 'POST' || method === 'PATCH')) {
65
+ opts.body = JSON.stringify(body);
66
+ }
67
+
68
+ const url = `${this.url}/rest/v1/${path}`;
69
+ const res = await fetch(url, opts);
70
+
71
+ if (!res.ok) {
72
+ const text = await res.text().catch(() => '');
73
+ const msg = `Supabase ${method} ${path}: ${res.status} ${text.slice(0, 200)}`;
74
+ log.error('supabase', msg);
75
+ throw new Error(msg);
76
+ }
77
+
78
+ const text = await res.text();
79
+ if (!text) return method === 'GET' ? [] : {};
80
+
81
+ try {
82
+ return JSON.parse(text);
83
+ } catch {
84
+ return text;
85
+ }
86
+ }
87
+ }