@simonfestl/husky-cli 1.33.1 → 1.35.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/dist/commands/plan.js +95 -0
- package/dist/commands/supervisor.d.ts +2 -0
- package/dist/commands/supervisor.js +528 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
package/dist/commands/plan.js
CHANGED
|
@@ -215,5 +215,100 @@ program
|
|
|
215
215
|
process.exit(1);
|
|
216
216
|
}
|
|
217
217
|
});
|
|
218
|
+
/**
|
|
219
|
+
* Wait for plan approval (worker command)
|
|
220
|
+
*
|
|
221
|
+
* Exit codes:
|
|
222
|
+
* 0 = approved
|
|
223
|
+
* 1 = rejected
|
|
224
|
+
* 2 = revision_requested
|
|
225
|
+
* 3 = timeout
|
|
226
|
+
*/
|
|
227
|
+
program
|
|
228
|
+
.command("await")
|
|
229
|
+
.description("Wait for supervisor to approve/reject a plan (worker)")
|
|
230
|
+
.argument("<id>", "Plan ID to wait for")
|
|
231
|
+
.option("--timeout <seconds>", "Timeout in seconds (default: 300)", "300")
|
|
232
|
+
.option("--interval <seconds>", "Poll interval in seconds (default: 5)", "5")
|
|
233
|
+
.option("--json", "Output result as JSON")
|
|
234
|
+
.action(async (id, options) => {
|
|
235
|
+
const timeout = parseInt(options.timeout, 10) * 1000;
|
|
236
|
+
const interval = parseInt(options.interval, 10) * 1000;
|
|
237
|
+
const startTime = Date.now();
|
|
238
|
+
const api = getApiClient();
|
|
239
|
+
if (!options.json) {
|
|
240
|
+
console.log(chalk.yellow(`⏳ Waiting for plan ${id} approval...`));
|
|
241
|
+
console.log(chalk.dim(` Timeout: ${options.timeout}s, Poll interval: ${options.interval}s`));
|
|
242
|
+
}
|
|
243
|
+
while (true) {
|
|
244
|
+
try {
|
|
245
|
+
const result = await api.get(`/api/plans/${id}`);
|
|
246
|
+
const { plan } = result;
|
|
247
|
+
if (plan.status === "approved") {
|
|
248
|
+
if (options.json) {
|
|
249
|
+
console.log(JSON.stringify({ status: "approved", notes: plan.supervisorNotes }));
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
console.log(chalk.green("\n✓ Plan approved!"));
|
|
253
|
+
if (plan.supervisorNotes) {
|
|
254
|
+
console.log(chalk.dim(` Supervisor notes: ${plan.supervisorNotes}`));
|
|
255
|
+
}
|
|
256
|
+
console.log(chalk.dim(" You may proceed with implementation."));
|
|
257
|
+
}
|
|
258
|
+
process.exit(0);
|
|
259
|
+
}
|
|
260
|
+
if (plan.status === "rejected") {
|
|
261
|
+
if (options.json) {
|
|
262
|
+
console.log(JSON.stringify({ status: "rejected", notes: plan.supervisorNotes }));
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
console.log(chalk.red("\n✗ Plan rejected"));
|
|
266
|
+
if (plan.supervisorNotes) {
|
|
267
|
+
console.log(chalk.dim(` Reason: ${plan.supervisorNotes}`));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
if (plan.status === "revision_requested") {
|
|
273
|
+
if (options.json) {
|
|
274
|
+
console.log(JSON.stringify({ status: "revision_requested", notes: plan.supervisorNotes }));
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
console.log(chalk.blue("\n🔄 Revision requested"));
|
|
278
|
+
if (plan.supervisorNotes) {
|
|
279
|
+
console.log(chalk.dim(` Feedback: ${plan.supervisorNotes}`));
|
|
280
|
+
}
|
|
281
|
+
console.log(chalk.dim(" Please update and resubmit the plan."));
|
|
282
|
+
}
|
|
283
|
+
process.exit(2);
|
|
284
|
+
}
|
|
285
|
+
// Still pending - check timeout
|
|
286
|
+
if (Date.now() - startTime >= timeout) {
|
|
287
|
+
if (options.json) {
|
|
288
|
+
console.log(JSON.stringify({ status: "timeout", elapsed: Math.round((Date.now() - startTime) / 1000) }));
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
console.log(chalk.yellow("\n⏱️ Timeout reached"));
|
|
292
|
+
console.log(chalk.dim(` Plan is still pending after ${options.timeout}s`));
|
|
293
|
+
}
|
|
294
|
+
process.exit(3);
|
|
295
|
+
}
|
|
296
|
+
// Wait before next poll
|
|
297
|
+
if (!options.json) {
|
|
298
|
+
process.stdout.write(".");
|
|
299
|
+
}
|
|
300
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
if (options.json) {
|
|
304
|
+
console.log(JSON.stringify({ status: "error", message: error instanceof Error ? error.message : String(error) }));
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
console.error(chalk.red("\n✗ Error checking plan status:"), error instanceof Error ? error.message : error);
|
|
308
|
+
}
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
});
|
|
218
313
|
export const planCommand = program;
|
|
219
314
|
export default program;
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { getConfig } from "./config.js";
|
|
3
|
+
import { spawn, execSync } from "child_process";
|
|
4
|
+
import { promisify } from "util";
|
|
5
|
+
import { exec } from "child_process";
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
export const supervisorCommand = new Command("supervisor")
|
|
8
|
+
.description("Manage Husky Supervisor Agent");
|
|
9
|
+
// supervisor start
|
|
10
|
+
supervisorCommand
|
|
11
|
+
.command("start")
|
|
12
|
+
.description("Start the supervisor agent")
|
|
13
|
+
.option("--session <name>", "Tmux session name", "supervisor")
|
|
14
|
+
.option("--id <id>", "Agent ID", "supervisor")
|
|
15
|
+
.option("--name <name>", "Agent display name", "Husky Supervisor")
|
|
16
|
+
.option("--vm <name>", "VM name (auto-detected if not provided)")
|
|
17
|
+
.action(async (options) => {
|
|
18
|
+
const config = getConfig();
|
|
19
|
+
if (!config.apiUrl) {
|
|
20
|
+
console.error("Error: API URL not configured. Run: husky config set api-url <url>");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
console.log("🐕 Starting Husky Supervisor Agent...");
|
|
24
|
+
// Set environment variables
|
|
25
|
+
const env = {
|
|
26
|
+
...process.env,
|
|
27
|
+
HUSKY_API_URL: config.apiUrl,
|
|
28
|
+
HUSKY_API_KEY: config.apiKey,
|
|
29
|
+
HUSKY_AGENT_ID: options.id,
|
|
30
|
+
HUSKY_SUPERVISOR_SESSION: options.session,
|
|
31
|
+
};
|
|
32
|
+
// Get the supervisor script path
|
|
33
|
+
const scriptPath = new URL("../scripts/supervisor-start.sh", import.meta.url).pathname;
|
|
34
|
+
try {
|
|
35
|
+
// Run the supervisor script
|
|
36
|
+
const child = spawn(scriptPath, [], {
|
|
37
|
+
env,
|
|
38
|
+
stdio: "inherit",
|
|
39
|
+
});
|
|
40
|
+
child.on("close", (code) => {
|
|
41
|
+
if (code !== 0) {
|
|
42
|
+
console.error(`Supervisor script exited with code ${code}`);
|
|
43
|
+
process.exit(code);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
console.error("Error starting supervisor:", error);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
// supervisor status
|
|
53
|
+
supervisorCommand
|
|
54
|
+
.command("status")
|
|
55
|
+
.description("Check supervisor status")
|
|
56
|
+
.option("--session <name>", "Tmux session name", "supervisor")
|
|
57
|
+
.action(async (options) => {
|
|
58
|
+
try {
|
|
59
|
+
const { stdout } = await execAsync(`tmux has-session -t "${options.session}"`);
|
|
60
|
+
console.log("✅ Supervisor is running");
|
|
61
|
+
console.log(`Session: ${options.session}`);
|
|
62
|
+
// List windows
|
|
63
|
+
const { stdout: windows } = await execAsync(`tmux list-windows -t "${options.session}"`);
|
|
64
|
+
console.log("\nActive windows:");
|
|
65
|
+
console.log(windows);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
console.log("❌ Supervisor is not running");
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
// supervisor stop
|
|
73
|
+
supervisorCommand
|
|
74
|
+
.command("stop")
|
|
75
|
+
.description("Stop the supervisor agent")
|
|
76
|
+
.option("--session <name>", "Tmux session name", "supervisor")
|
|
77
|
+
.action(async (options) => {
|
|
78
|
+
try {
|
|
79
|
+
await execAsync(`tmux kill-session -t "${options.session}"`);
|
|
80
|
+
console.log("✅ Supervisor stopped successfully");
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
console.log("❌ Supervisor is not running or failed to stop");
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
// supervisor restart
|
|
88
|
+
supervisorCommand
|
|
89
|
+
.command("restart")
|
|
90
|
+
.description("Restart the supervisor agent")
|
|
91
|
+
.option("--session <name>", "Tmux session name", "supervisor")
|
|
92
|
+
.action(async (options) => {
|
|
93
|
+
try {
|
|
94
|
+
console.log("🔄 Restarting supervisor...");
|
|
95
|
+
await execAsync(`tmux kill-session -t "${options.session}"`);
|
|
96
|
+
console.log("✅ Old supervisor stopped");
|
|
97
|
+
// Start new supervisor
|
|
98
|
+
const scriptPath = new URL("../scripts/supervisor-start.sh", import.meta.url).pathname;
|
|
99
|
+
const config = getConfig();
|
|
100
|
+
const child = spawn(scriptPath, [], {
|
|
101
|
+
env: {
|
|
102
|
+
...process.env,
|
|
103
|
+
HUSKY_API_URL: config.apiUrl,
|
|
104
|
+
HUSKY_API_KEY: config.apiKey,
|
|
105
|
+
},
|
|
106
|
+
stdio: "inherit",
|
|
107
|
+
});
|
|
108
|
+
child.on("close", (code) => {
|
|
109
|
+
if (code !== 0) {
|
|
110
|
+
console.error(`Supervisor restart failed with code ${code}`);
|
|
111
|
+
process.exit(code);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
console.error("Error restarting supervisor:", error);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
// supervisor attach
|
|
121
|
+
supervisorCommand
|
|
122
|
+
.command("attach")
|
|
123
|
+
.description("Attach to the supervisor tmux session")
|
|
124
|
+
.option("--session <name>", "Tmux session name", "supervisor")
|
|
125
|
+
.option("--window <name>", "Window name or number (default: supervisor)", "supervisor")
|
|
126
|
+
.action(async (options) => {
|
|
127
|
+
try {
|
|
128
|
+
const { stdout } = await execAsync(`tmux has-session -t "${options.session}"`);
|
|
129
|
+
// Attach to specific window
|
|
130
|
+
spawn("tmux", ["attach", "-t", `${options.session}:${options.window}`], {
|
|
131
|
+
stdio: "inherit",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
console.error("❌ Supervisor is not running");
|
|
136
|
+
console.log("Start it with: husky supervisor start");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
// supervisor logs
|
|
141
|
+
supervisorCommand
|
|
142
|
+
.command("logs")
|
|
143
|
+
.description("Show logs from supervisor windows")
|
|
144
|
+
.option("--session <name>", "Tmux session name", "supervisor")
|
|
145
|
+
.option("--window <name>", "Window name (supervisor, messages, tasks, vms, heartbeat)")
|
|
146
|
+
.option("--lines <n>", "Number of lines to show", "50")
|
|
147
|
+
.action(async (options) => {
|
|
148
|
+
try {
|
|
149
|
+
await execAsync(`tmux has-session -t "${options.session}"`);
|
|
150
|
+
const window = options.window || "supervisor";
|
|
151
|
+
const lines = options.lines;
|
|
152
|
+
console.log(`📋 Showing last ${lines} lines from '${window}' window:\n`);
|
|
153
|
+
// Capture output from tmux pane
|
|
154
|
+
const { stdout } = await execAsync(`tmux capture-pane -t "${options.session}:${window}" -p | tail -n ${lines}`);
|
|
155
|
+
console.log(stdout);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
console.error("❌ Failed to get logs");
|
|
159
|
+
if (error instanceof Error && error.message.includes("session")) {
|
|
160
|
+
console.error("Supervisor is not running");
|
|
161
|
+
}
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
// supervisor message
|
|
166
|
+
supervisorCommand
|
|
167
|
+
.command("message")
|
|
168
|
+
.description("Send a message to the supervisor interface")
|
|
169
|
+
.argument("<message>", "Message to send")
|
|
170
|
+
.option("--session <name>", "Tmux session name", "supervisor")
|
|
171
|
+
.action(async (message, options) => {
|
|
172
|
+
try {
|
|
173
|
+
await execAsync(`tmux has-session -t "${options.session}"`);
|
|
174
|
+
// Send message to supervisor window
|
|
175
|
+
const escapedMessage = message
|
|
176
|
+
.replace(/\\/g, "\\\\")
|
|
177
|
+
.replace(/"/g, '\\"')
|
|
178
|
+
.replace(/\$/g, "\\$");
|
|
179
|
+
await execAsync(`tmux send-keys -t "${options.session}:supervisor" "${escapedMessage}" Enter`);
|
|
180
|
+
console.log("✅ Message sent to supervisor");
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
console.error("❌ Failed to send message");
|
|
184
|
+
if (error instanceof Error && error.message.includes("session")) {
|
|
185
|
+
console.error("Supervisor is not running");
|
|
186
|
+
}
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
// supervisor execute
|
|
191
|
+
supervisorCommand
|
|
192
|
+
.command("execute")
|
|
193
|
+
.description("Execute a command in the supervisor interface")
|
|
194
|
+
.argument("<command>", "Command to execute")
|
|
195
|
+
.option("--session <name>", "Tmux session name", "supervisor")
|
|
196
|
+
.option("--window <name>", "Window to execute in", "supervisor")
|
|
197
|
+
.action(async (command, options) => {
|
|
198
|
+
try {
|
|
199
|
+
await execAsync(`tmux has-session -t "${options.session}"`);
|
|
200
|
+
// Send command to specified window
|
|
201
|
+
const escapedCommand = command
|
|
202
|
+
.replace(/\\/g, "\\\\")
|
|
203
|
+
.replace(/"/g, '\\"')
|
|
204
|
+
.replace(/\$/g, "\\$");
|
|
205
|
+
await execAsync(`tmux send-keys -t "${options.session}:${options.window}" "${escapedCommand}" Enter`);
|
|
206
|
+
console.log(`✅ Command sent to ${options.window} window`);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
console.error("❌ Failed to execute command");
|
|
210
|
+
if (error instanceof Error && error.message.includes("session")) {
|
|
211
|
+
console.error("Supervisor is not running");
|
|
212
|
+
}
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
// supervisor workflow
|
|
217
|
+
supervisorCommand
|
|
218
|
+
.command("workflow")
|
|
219
|
+
.description("Show supervisor workflow status and next actions")
|
|
220
|
+
.option("--json", "Output as JSON")
|
|
221
|
+
.action(async (options) => {
|
|
222
|
+
const config = getConfig();
|
|
223
|
+
if (!config.apiUrl) {
|
|
224
|
+
console.error("Error: API URL not configured. Run: husky config set api-url <url>");
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
// Get task backlog
|
|
229
|
+
const tasksRes = await fetch(`${config.apiUrl}/api/tasks?status=backlog&assignee=llm&limit=10`, {
|
|
230
|
+
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
231
|
+
});
|
|
232
|
+
// Get VM status
|
|
233
|
+
const vmCommand = "gcloud compute instances list --filter='name~husky-' --format='json(name,status,zone,machineType)'";
|
|
234
|
+
const { stdout: vmOutput } = await execAsync(vmCommand);
|
|
235
|
+
const vms = JSON.parse(vmOutput || "[]");
|
|
236
|
+
// Get registered agents
|
|
237
|
+
const agentsRes = await fetch(`${config.apiUrl}/api/agents`, {
|
|
238
|
+
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
239
|
+
});
|
|
240
|
+
const tasks = tasksRes.ok ? await tasksRes.json() : { tasks: [] };
|
|
241
|
+
const agents = agentsRes.ok ? await agentsRes.json() : { agents: [] };
|
|
242
|
+
if (options.json) {
|
|
243
|
+
console.log(JSON.stringify({
|
|
244
|
+
tasks: tasks.tasks || [],
|
|
245
|
+
vms,
|
|
246
|
+
agents: agents.agents || [],
|
|
247
|
+
timestamp: new Date().toISOString(),
|
|
248
|
+
}, null, 2));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
console.log("\n🐕 Husky Supervisor Workflow Status");
|
|
252
|
+
console.log("=".repeat(50));
|
|
253
|
+
// Task status
|
|
254
|
+
console.log("\n📋 Task Backlog:");
|
|
255
|
+
const backlogTasks = tasks.tasks || [];
|
|
256
|
+
if (backlogTasks.length === 0) {
|
|
257
|
+
console.log(" No tasks in backlog");
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
backlogTasks.forEach((task, i) => {
|
|
261
|
+
console.log(` ${i + 1}. ${task.title} (${task.id})`);
|
|
262
|
+
console.log(` Priority: ${task.priority} | Assignee: ${task.assignee}`);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
// VM status
|
|
266
|
+
console.log("\n🖥️ VM Fleet Status:");
|
|
267
|
+
const runningWorkers = vms.filter((vm) => vm.name.includes("worker") && vm.status === "RUNNING");
|
|
268
|
+
const suspendedWorkers = vms.filter((vm) => vm.name.includes("worker") && vm.status === "SUSPENDED");
|
|
269
|
+
const terminatedWorkers = vms.filter((vm) => vm.name.includes("worker") && vm.status === "TERMINATED");
|
|
270
|
+
console.log(` Running workers: ${runningWorkers.length}`);
|
|
271
|
+
console.log(` Suspended workers: ${suspendedWorkers.length}`);
|
|
272
|
+
console.log(` Terminated workers: ${terminatedWorkers.length}`);
|
|
273
|
+
if (vms.find((vm) => vm.name.includes("supervisor"))) {
|
|
274
|
+
const supervisorVm = vms.find((vm) => vm.name.includes("supervisor"));
|
|
275
|
+
console.log(` Supervisor VM: ${supervisorVm.status}`);
|
|
276
|
+
}
|
|
277
|
+
// Agent status
|
|
278
|
+
console.log("\n🤖 Registered Agents:");
|
|
279
|
+
const agentList = agents.agents || [];
|
|
280
|
+
const onlineAgents = agentList.filter((agent) => agent.status === "online");
|
|
281
|
+
const busyAgents = agentList.filter((agent) => agent.status === "busy");
|
|
282
|
+
const offlineAgents = agentList.filter((agent) => agent.status === "offline");
|
|
283
|
+
console.log(` Online: ${onlineAgents.length}, Busy: ${busyAgents.length}, Offline: ${offlineAgents.length}`);
|
|
284
|
+
// Next actions
|
|
285
|
+
console.log("\n🎯 Suggested Actions:");
|
|
286
|
+
if (backlogTasks.length > 0 && runningWorkers.length === 0) {
|
|
287
|
+
console.log(" • Start worker VM(s) to handle task backlog");
|
|
288
|
+
}
|
|
289
|
+
if (terminatedWorkers.length > 0 && backlogTasks.length > 0) {
|
|
290
|
+
console.log(" • Resume terminated worker VMs");
|
|
291
|
+
}
|
|
292
|
+
if (backlogTasks.length > 0) {
|
|
293
|
+
console.log(" • Assign tasks to available workers");
|
|
294
|
+
}
|
|
295
|
+
if (offlineAgents.length > 0) {
|
|
296
|
+
console.log(" • Check on offline agents");
|
|
297
|
+
}
|
|
298
|
+
if (backlogTasks.length === 0 && runningWorkers.length > 0) {
|
|
299
|
+
console.log(" • Suspend idle worker VMs to save costs");
|
|
300
|
+
}
|
|
301
|
+
console.log("\nQuick commands:");
|
|
302
|
+
console.log(" husky supervisor start - Start supervisor");
|
|
303
|
+
console.log(" husky supervisor attach - Attach to supervisor");
|
|
304
|
+
console.log(" husk supervisor status - Check status");
|
|
305
|
+
console.log("");
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
console.error("Error getting workflow status:", error);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
// supervisor cycle
|
|
313
|
+
supervisorCommand
|
|
314
|
+
.command("cycle")
|
|
315
|
+
.description("Run the main supervisor loop (for automation)")
|
|
316
|
+
.option("--interval <seconds>", "Loop interval in seconds", "300") // 5 minutes default
|
|
317
|
+
.option("--once", "Run once and exit")
|
|
318
|
+
.action(async (options) => {
|
|
319
|
+
const config = getConfig();
|
|
320
|
+
if (!config.apiUrl) {
|
|
321
|
+
console.error("Error: API URL not configured. Run: husky config set api-url <url>");
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
const interval = parseInt(options.interval) * 1000;
|
|
325
|
+
console.log(`🐕 Starting supervisor cycle${options.once ? ' (once)' : ` (every ${options.interval}s)`}...`);
|
|
326
|
+
const runCycle = async () => {
|
|
327
|
+
try {
|
|
328
|
+
console.log(`\n[${new Date().toLocaleTimeString()}] Running supervisor cycle...`);
|
|
329
|
+
// Step 1: Check for new tasks
|
|
330
|
+
const tasksRes = await fetch(`${config.apiUrl}/api/tasks?status=backlog&assignee=llm&limit=5`, {
|
|
331
|
+
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
332
|
+
});
|
|
333
|
+
if (tasksRes.ok) {
|
|
334
|
+
const tasksData = await tasksRes.json();
|
|
335
|
+
const tasks = tasksData.tasks || [];
|
|
336
|
+
if (tasks.length > 0) {
|
|
337
|
+
console.log(` Found ${tasks.length} tasks in backlog`);
|
|
338
|
+
// Step 2: Check available worker VMs
|
|
339
|
+
const { stdout: vmOutput } = await execAsync("gcloud compute instances list --filter='name~husky-worker' --format='json(name,status,zone)'");
|
|
340
|
+
const vms = JSON.parse(vmOutput || "[]");
|
|
341
|
+
const runningWorkers = vms.filter((vm) => vm.name.includes("worker") && vm.status === "RUNNING");
|
|
342
|
+
if (runningWorkers.length === 0) {
|
|
343
|
+
console.log(" No worker VMs running, starting one...");
|
|
344
|
+
// This would need to be implemented based on your GCP setup
|
|
345
|
+
console.log(" TODO: Implement VM startup logic");
|
|
346
|
+
}
|
|
347
|
+
// Step 3: Assign tasks to workers
|
|
348
|
+
tasks.forEach((task) => {
|
|
349
|
+
console.log(` Task ${task.id}: ${task.title}`);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
console.log(" No tasks in backlog");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
console.log(`[${new Date().toLocaleTimeString()}] Cycle completed`);
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
console.error(`[${new Date().toLocaleTimeString()}] Cycle error:`, error);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
if (options.once) {
|
|
363
|
+
await runCycle();
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
await runCycle();
|
|
367
|
+
setInterval(runCycle, interval);
|
|
368
|
+
console.log(`\nSupervisor cycle running. Press Ctrl+C to stop.`);
|
|
369
|
+
// Keep process alive
|
|
370
|
+
process.on("SIGINT", () => {
|
|
371
|
+
console.log("\n👋 Stopping supervisor cycle...");
|
|
372
|
+
process.exit(0);
|
|
373
|
+
});
|
|
374
|
+
await new Promise(() => { });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
function checkGitHubPR(branch) {
|
|
378
|
+
try {
|
|
379
|
+
const result = execSync(`gh pr view "${branch}" --json url,mergedAt,state -q '{"url":.url,"mergedAt":.mergedAt,"state":.state}'`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
380
|
+
const data = JSON.parse(result.trim());
|
|
381
|
+
return {
|
|
382
|
+
exists: true,
|
|
383
|
+
merged: data.mergedAt !== null && data.mergedAt !== "",
|
|
384
|
+
url: data.url,
|
|
385
|
+
state: data.state,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return { exists: false, merged: false };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
supervisorCommand
|
|
393
|
+
.command("auto-track")
|
|
394
|
+
.description("Automatic task status tracking based on GitHub PR state")
|
|
395
|
+
.option("--interval <minutes>", "Check interval in minutes", "5")
|
|
396
|
+
.option("--dry-run", "Show what would be updated without making changes")
|
|
397
|
+
.option("--once", "Run once and exit")
|
|
398
|
+
.option("--stale-hours <hours>", "Hours before task is considered stale", "24")
|
|
399
|
+
.option("--alert", "Send alerts for stale tasks to Google Chat")
|
|
400
|
+
.action(async (options) => {
|
|
401
|
+
const config = getConfig();
|
|
402
|
+
if (!config.apiUrl) {
|
|
403
|
+
console.error("Error: API URL not configured. Run: husky config set api-url <url>");
|
|
404
|
+
process.exit(1);
|
|
405
|
+
}
|
|
406
|
+
const intervalMs = parseInt(options.interval) * 60 * 1000;
|
|
407
|
+
const staleMs = parseInt(options.staleHours) * 60 * 60 * 1000;
|
|
408
|
+
const headers = {};
|
|
409
|
+
if (config.apiKey)
|
|
410
|
+
headers["x-api-key"] = config.apiKey;
|
|
411
|
+
console.log(`🔄 Starting auto-tracking loop (interval: ${options.interval}m, stale: ${options.staleHours}h)`);
|
|
412
|
+
if (options.dryRun)
|
|
413
|
+
console.log(" DRY RUN MODE - no changes will be made\n");
|
|
414
|
+
async function checkAndUpdate() {
|
|
415
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
416
|
+
console.log(`\n[${timestamp}] Checking task statuses...`);
|
|
417
|
+
try {
|
|
418
|
+
const res = await fetch(`${config.apiUrl}/api/tasks?status=in_progress,review&limit=50`, { headers });
|
|
419
|
+
if (!res.ok) {
|
|
420
|
+
console.error(` ❌ Failed to fetch tasks: ${res.status}`);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const data = await res.json();
|
|
424
|
+
const tasks = data.tasks || [];
|
|
425
|
+
if (tasks.length === 0) {
|
|
426
|
+
console.log(" No in_progress or review tasks found");
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
console.log(` Found ${tasks.length} active tasks`);
|
|
430
|
+
const staleTasks = [];
|
|
431
|
+
const now = Date.now();
|
|
432
|
+
for (const task of tasks) {
|
|
433
|
+
const taskAge = now - new Date(task.updatedAt).getTime();
|
|
434
|
+
const isStale = taskAge > staleMs;
|
|
435
|
+
if (isStale) {
|
|
436
|
+
staleTasks.push(task);
|
|
437
|
+
}
|
|
438
|
+
if (!task.worktreeBranch) {
|
|
439
|
+
console.log(` 📌 ${task.id}: No branch linked (${task.status})${isStale ? " ⚠️ STALE" : ""}`);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const prStatus = checkGitHubPR(task.worktreeBranch);
|
|
443
|
+
if (prStatus.merged && task.status !== "done") {
|
|
444
|
+
console.log(` ✅ ${task.id}: PR merged → marking done`);
|
|
445
|
+
if (!options.dryRun) {
|
|
446
|
+
const updateRes = await fetch(`${config.apiUrl}/api/tasks/${task.id}`, {
|
|
447
|
+
method: "PATCH",
|
|
448
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
449
|
+
body: JSON.stringify({
|
|
450
|
+
status: "done",
|
|
451
|
+
statusMessage: "PR merged - auto-tracked by supervisor",
|
|
452
|
+
}),
|
|
453
|
+
});
|
|
454
|
+
if (!updateRes.ok) {
|
|
455
|
+
console.error(` ❌ Failed to update task: ${updateRes.status}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
else if (prStatus.exists && task.status === "in_progress") {
|
|
460
|
+
console.log(` 📝 ${task.id}: PR exists (${prStatus.state}) → marking review`);
|
|
461
|
+
if (!options.dryRun) {
|
|
462
|
+
const updateRes = await fetch(`${config.apiUrl}/api/tasks/${task.id}`, {
|
|
463
|
+
method: "PATCH",
|
|
464
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
465
|
+
body: JSON.stringify({
|
|
466
|
+
status: "review",
|
|
467
|
+
prUrl: prStatus.url,
|
|
468
|
+
statusMessage: "PR opened - auto-tracked by supervisor",
|
|
469
|
+
}),
|
|
470
|
+
});
|
|
471
|
+
if (!updateRes.ok) {
|
|
472
|
+
console.error(` ❌ Failed to update task: ${updateRes.status}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
else if (!prStatus.exists && task.status === "in_progress") {
|
|
477
|
+
console.log(` 🔨 ${task.id}: In progress, no PR yet${isStale ? " ⚠️ STALE" : ""}`);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
console.log(` ℹ️ ${task.id}: ${task.status} (PR: ${prStatus.exists ? prStatus.state : "none"})${isStale ? " ⚠️ STALE" : ""}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (staleTasks.length > 0) {
|
|
484
|
+
console.log(`\n ⚠️ Found ${staleTasks.length} stale tasks (>${options.staleHours}h inactive):`);
|
|
485
|
+
staleTasks.forEach((t) => {
|
|
486
|
+
const hours = Math.round((now - new Date(t.updatedAt).getTime()) / (1000 * 60 * 60));
|
|
487
|
+
console.log(` - ${t.title} (${t.id}) - ${hours}h inactive`);
|
|
488
|
+
});
|
|
489
|
+
if (options.alert && !options.dryRun) {
|
|
490
|
+
const alertMessage = `⚠️ **Stale Task Alert**\n\n${staleTasks.length} tasks inactive for >${options.staleHours}h:\n\n${staleTasks.map((t) => `- ${t.title} (${t.id})`).join("\n")}\n\nPlease review and update status.`;
|
|
491
|
+
try {
|
|
492
|
+
const chatRes = await fetch(`${config.apiUrl}/api/google-chat/send`, {
|
|
493
|
+
method: "POST",
|
|
494
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
495
|
+
body: JSON.stringify({
|
|
496
|
+
text: alertMessage,
|
|
497
|
+
spaceName: process.env.GOOGLE_CHAT_DM_SPACE,
|
|
498
|
+
}),
|
|
499
|
+
});
|
|
500
|
+
if (chatRes.ok) {
|
|
501
|
+
console.log(" 📣 Stale task alert sent to Google Chat");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
console.error(" ❌ Failed to send alert:", error);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
console.log(`[${new Date().toLocaleTimeString()}] Check completed`);
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
console.error(`[${timestamp}] Error:`, error);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (options.once) {
|
|
516
|
+
await checkAndUpdate();
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
await checkAndUpdate();
|
|
520
|
+
setInterval(checkAndUpdate, intervalMs);
|
|
521
|
+
console.log(`\nAuto-tracking running. Press Ctrl+C to stop.`);
|
|
522
|
+
process.on("SIGINT", () => {
|
|
523
|
+
console.log("\n👋 Stopping auto-tracking...");
|
|
524
|
+
process.exit(0);
|
|
525
|
+
});
|
|
526
|
+
await new Promise(() => { });
|
|
527
|
+
}
|
|
528
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -38,6 +38,7 @@ import { authCommand } from "./commands/auth.js";
|
|
|
38
38
|
import { businessCommand } from "./commands/business.js";
|
|
39
39
|
import { planCommand } from "./commands/plan.js";
|
|
40
40
|
import { diagramsCommand } from "./commands/diagrams.js";
|
|
41
|
+
import { supervisorCommand } from "./commands/supervisor.js";
|
|
41
42
|
import { checkVersion } from "./lib/version-check.js";
|
|
42
43
|
// Read version from package.json
|
|
43
44
|
const require = createRequire(import.meta.url);
|
|
@@ -84,6 +85,7 @@ program.addCommand(authCommand);
|
|
|
84
85
|
program.addCommand(businessCommand);
|
|
85
86
|
program.addCommand(planCommand);
|
|
86
87
|
program.addCommand(diagramsCommand);
|
|
88
|
+
program.addCommand(supervisorCommand);
|
|
87
89
|
// Handle --llm flag specially
|
|
88
90
|
if (process.argv.includes("--llm")) {
|
|
89
91
|
printLLMContext();
|