@gethmy/agent 1.0.0 → 1.0.1
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/README.md +5 -5
- package/dist/board-helpers.d.ts +23 -0
- package/dist/board-helpers.js +131 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +2 -11761
- package/dist/completion.d.ts +7 -0
- package/dist/completion.js +132 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.js +91 -0
- package/dist/git-pr.d.ts +25 -0
- package/dist/git-pr.js +305 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +165 -11730
- package/dist/log.d.ts +10 -0
- package/dist/log.js +35 -0
- package/dist/merge-monitor.d.ts +23 -0
- package/dist/merge-monitor.js +155 -0
- package/dist/pm.d.ts +14 -0
- package/dist/pm.js +63 -0
- package/dist/pool.d.ts +36 -0
- package/dist/pool.js +134 -0
- package/dist/progress-tracker.d.ts +39 -0
- package/dist/progress-tracker.js +189 -0
- package/dist/prompt.d.ts +5 -0
- package/dist/prompt.js +40 -0
- package/dist/queue.d.ts +37 -0
- package/dist/queue.js +96 -0
- package/dist/reconcile.d.ts +21 -0
- package/dist/reconcile.js +107 -0
- package/dist/review-completion.d.ts +31 -0
- package/dist/review-completion.js +247 -0
- package/dist/review-knowledge.d.ts +14 -0
- package/dist/review-knowledge.js +89 -0
- package/dist/review-prompt.d.ts +12 -0
- package/dist/review-prompt.js +100 -0
- package/dist/review-worker.d.ts +35 -0
- package/dist/review-worker.js +302 -0
- package/dist/review-worktree.d.ts +12 -0
- package/dist/review-worktree.js +83 -0
- package/dist/stream-parser.d.ts +22 -0
- package/dist/stream-parser.js +81 -0
- package/dist/types.d.ts +74 -0
- package/dist/types.js +53 -0
- package/dist/verification.d.ts +16 -0
- package/dist/verification.js +251 -0
- package/dist/watcher.d.ts +21 -0
- package/dist/watcher.js +62 -0
- package/dist/worker.d.ts +34 -0
- package/dist/worker.js +268 -0
- package/dist/worktree.d.ts +13 -0
- package/dist/worktree.js +115 -0
- package/package.json +6 -5
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { addLabelByName, moveCardToColumn } from "./board-helpers.js";
|
|
3
|
+
import { log } from "./log.js";
|
|
4
|
+
import { parseReviewOutput, runReviewCompletion } from "./review-completion.js";
|
|
5
|
+
import { buildReviewSystemPrompt, buildReviewUserPrompt, } from "./review-prompt.js";
|
|
6
|
+
import { checkoutExistingBranch, extractBranchFromDescription, } from "./review-worktree.js";
|
|
7
|
+
import { AGENT_NAME, agentIdentifier, } from "./types.js";
|
|
8
|
+
import { waitForDevServer } from "./verification.js";
|
|
9
|
+
import { cleanupWorktree } from "./worktree.js";
|
|
10
|
+
const TAG = "review-worker";
|
|
11
|
+
const CANCEL_SIGINT_TIMEOUT = 30_000;
|
|
12
|
+
const CANCEL_SIGTERM_TIMEOUT = 10_000;
|
|
13
|
+
export class ReviewWorker {
|
|
14
|
+
config;
|
|
15
|
+
client;
|
|
16
|
+
onDone;
|
|
17
|
+
id;
|
|
18
|
+
state = "idle";
|
|
19
|
+
cardId = null;
|
|
20
|
+
branchName = null;
|
|
21
|
+
worktreePath = null;
|
|
22
|
+
startedAt = null;
|
|
23
|
+
process = null;
|
|
24
|
+
devServerProcess = null;
|
|
25
|
+
timeoutTimer = null;
|
|
26
|
+
aborted = false;
|
|
27
|
+
constructor(id, config, client, _userEmail, onDone) {
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.client = client;
|
|
30
|
+
this.onDone = onDone;
|
|
31
|
+
this.id = id;
|
|
32
|
+
}
|
|
33
|
+
get tag() {
|
|
34
|
+
return `${TAG}:${this.id}`;
|
|
35
|
+
}
|
|
36
|
+
get isIdle() {
|
|
37
|
+
return this.state === "idle";
|
|
38
|
+
}
|
|
39
|
+
get isActive() {
|
|
40
|
+
return (this.state === "preparing" ||
|
|
41
|
+
this.state === "running" ||
|
|
42
|
+
this.state === "verifying" ||
|
|
43
|
+
this.state === "completing");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Start reviewing a card. Runs the full lifecycle:
|
|
47
|
+
* PREPARING → REVIEWING → COMPLETING → IDLE
|
|
48
|
+
*/
|
|
49
|
+
async run(card, column, labels, subtasks) {
|
|
50
|
+
this.aborted = false;
|
|
51
|
+
this.cardId = card.id;
|
|
52
|
+
this.startedAt = Date.now();
|
|
53
|
+
try {
|
|
54
|
+
// --- PREPARING ---
|
|
55
|
+
this.state = "preparing";
|
|
56
|
+
log.info(this.tag, `Preparing review for #${card.short_id} "${card.title}"`);
|
|
57
|
+
// Extract branch name from card description
|
|
58
|
+
this.branchName = extractBranchFromDescription(card.description);
|
|
59
|
+
if (!this.branchName) {
|
|
60
|
+
throw new Error(`No branch name found in card #${card.short_id} description. ` +
|
|
61
|
+
"Expected: Branch: `agent/...`");
|
|
62
|
+
}
|
|
63
|
+
log.info(this.tag, `Review branch: ${this.branchName}`);
|
|
64
|
+
// Start agent session and make it visible on the board
|
|
65
|
+
await this.client.startAgentSession(card.id, {
|
|
66
|
+
agentIdentifier: agentIdentifier(this.id),
|
|
67
|
+
agentName: `${AGENT_NAME} (Review)`,
|
|
68
|
+
status: "working",
|
|
69
|
+
currentTask: "Setting up review worktree",
|
|
70
|
+
progressPercent: 5,
|
|
71
|
+
});
|
|
72
|
+
// Fire label addition concurrently with sync worktree checkout
|
|
73
|
+
const labelPromise = addLabelByName(this.client, card, "agent", "#8b5cf6");
|
|
74
|
+
// Checkout existing branch into worktree (sync)
|
|
75
|
+
this.worktreePath = checkoutExistingBranch(this.config.worktree.basePath, this.branchName);
|
|
76
|
+
await labelPromise;
|
|
77
|
+
if (this.aborted)
|
|
78
|
+
return;
|
|
79
|
+
// --- REVIEWING ---
|
|
80
|
+
this.state = "running";
|
|
81
|
+
// Start dev server
|
|
82
|
+
// Use devServerPort directly — review worker IDs are offset by poolSize
|
|
83
|
+
// so we subtract to get a 0-based index for port allocation
|
|
84
|
+
const port = this.config.review.devServerPort + (this.id - this.config.poolSize);
|
|
85
|
+
log.info(this.tag, `Starting dev server on port ${port}...`);
|
|
86
|
+
this.devServerProcess = spawn("bun", ["run", "dev", "--", "--port", String(port)], {
|
|
87
|
+
cwd: this.worktreePath,
|
|
88
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
89
|
+
});
|
|
90
|
+
await waitForDevServer(this.devServerProcess, 30_000);
|
|
91
|
+
log.info(this.tag, `Dev server ready on port ${port}`);
|
|
92
|
+
if (this.aborted)
|
|
93
|
+
return;
|
|
94
|
+
// Get diff
|
|
95
|
+
let diff = "";
|
|
96
|
+
try {
|
|
97
|
+
diff = execFileSync("git", ["diff", `origin/${this.config.worktree.baseBranch}..HEAD`], { cwd: this.worktreePath, encoding: "utf-8", timeout: 30_000 });
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
diff = "(unable to retrieve diff)";
|
|
101
|
+
}
|
|
102
|
+
const previewUrl = `http://localhost:${port}`;
|
|
103
|
+
const enriched = {
|
|
104
|
+
card,
|
|
105
|
+
column,
|
|
106
|
+
labels,
|
|
107
|
+
subtasks,
|
|
108
|
+
mode: "review",
|
|
109
|
+
};
|
|
110
|
+
const systemPrompt = buildReviewSystemPrompt();
|
|
111
|
+
const userPrompt = buildReviewUserPrompt(enriched, this.branchName, this.worktreePath, previewUrl, diff);
|
|
112
|
+
await this.client.updateAgentProgress(card.id, {
|
|
113
|
+
agentIdentifier: agentIdentifier(this.id),
|
|
114
|
+
agentName: `${AGENT_NAME} (Review)`,
|
|
115
|
+
status: "working",
|
|
116
|
+
currentTask: "Running Claude review",
|
|
117
|
+
progressPercent: 20,
|
|
118
|
+
});
|
|
119
|
+
// Start timeout watchdog
|
|
120
|
+
this.timeoutTimer = setTimeout(() => {
|
|
121
|
+
log.warn(this.tag, `Review timeout reached (${this.config.review.maxTimeout}ms), cancelling`);
|
|
122
|
+
this.cancel();
|
|
123
|
+
}, this.config.review.maxTimeout);
|
|
124
|
+
// Spawn Claude CLI for review
|
|
125
|
+
const stdout = await this.spawnClaude(userPrompt, systemPrompt);
|
|
126
|
+
if (this.aborted)
|
|
127
|
+
return;
|
|
128
|
+
// --- COMPLETING ---
|
|
129
|
+
this.state = "completing";
|
|
130
|
+
log.info(this.tag, `Claude review finished for #${card.short_id}`);
|
|
131
|
+
// Kill dev server
|
|
132
|
+
this.killDevServer();
|
|
133
|
+
// Parse findings
|
|
134
|
+
const result = parseReviewOutput(stdout);
|
|
135
|
+
log.info(this.tag, `Review verdict: ${result.verdict} (${result.findings.length} finding(s))`);
|
|
136
|
+
await this.client.updateAgentProgress(card.id, {
|
|
137
|
+
agentIdentifier: agentIdentifier(this.id),
|
|
138
|
+
agentName: `${AGENT_NAME} (Review)`,
|
|
139
|
+
status: "working",
|
|
140
|
+
currentTask: `Processing ${result.verdict} verdict`,
|
|
141
|
+
progressPercent: 80,
|
|
142
|
+
});
|
|
143
|
+
// Run review completion pipeline
|
|
144
|
+
await runReviewCompletion(this.client, card, result, this.config, this.worktreePath, this.branchName);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
this.state = "error";
|
|
148
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
149
|
+
log.error(this.tag, `Error reviewing #${card.short_id}: ${msg}`);
|
|
150
|
+
// End session as paused on error
|
|
151
|
+
try {
|
|
152
|
+
await this.client.endAgentSession(card.id, { status: "paused" });
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// best-effort
|
|
156
|
+
}
|
|
157
|
+
// Move card out of Review to break the re-enqueue loop
|
|
158
|
+
try {
|
|
159
|
+
await moveCardToColumn(this.client, card, this.config.review.failColumn);
|
|
160
|
+
log.info(this.tag, `Moved #${card.short_id} to "${this.config.review.failColumn}" after error`);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
log.warn(this.tag, "Failed to move card to fail column after error");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
this.cleanup();
|
|
168
|
+
this.state = "idle";
|
|
169
|
+
this.onDone(this);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Cancel the current review. Sends escalating signals to both processes.
|
|
174
|
+
*/
|
|
175
|
+
async cancel() {
|
|
176
|
+
if (!this.isActive)
|
|
177
|
+
return;
|
|
178
|
+
this.aborted = true;
|
|
179
|
+
this.state = "cancelling";
|
|
180
|
+
log.info(this.tag, `Cancelling review on ${this.cardId}`);
|
|
181
|
+
// Kill dev server first
|
|
182
|
+
this.killDevServer();
|
|
183
|
+
// Then kill Claude process with escalating signals
|
|
184
|
+
if (this.process && !this.process.killed) {
|
|
185
|
+
this.process.kill("SIGINT");
|
|
186
|
+
log.debug(this.tag, "Sent SIGINT to Claude");
|
|
187
|
+
const sigintDead = await this.waitForExit(CANCEL_SIGINT_TIMEOUT);
|
|
188
|
+
if (!sigintDead) {
|
|
189
|
+
this.process.kill("SIGTERM");
|
|
190
|
+
log.debug(this.tag, "Sent SIGTERM to Claude");
|
|
191
|
+
const sigtermDead = await this.waitForExit(CANCEL_SIGTERM_TIMEOUT);
|
|
192
|
+
if (!sigtermDead) {
|
|
193
|
+
this.process.kill("SIGKILL");
|
|
194
|
+
log.warn(this.tag, "Sent SIGKILL to Claude");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// End agent session as paused
|
|
199
|
+
if (this.cardId) {
|
|
200
|
+
try {
|
|
201
|
+
await this.client.endAgentSession(this.cardId, { status: "paused" });
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// best-effort
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
spawnClaude(prompt, systemPrompt) {
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
const args = [
|
|
211
|
+
"--print",
|
|
212
|
+
"--model",
|
|
213
|
+
this.config.claude.model,
|
|
214
|
+
"--max-turns",
|
|
215
|
+
String(this.config.claude.maxTurns),
|
|
216
|
+
"--allowedTools",
|
|
217
|
+
"Bash(readonly),Read,Glob,Grep,Agent,mcp__harmony__*",
|
|
218
|
+
...(systemPrompt ? ["--append-system-prompt", systemPrompt] : []),
|
|
219
|
+
...this.config.claude.additionalArgs,
|
|
220
|
+
"--",
|
|
221
|
+
prompt,
|
|
222
|
+
];
|
|
223
|
+
log.info(this.tag, `Spawning review: claude ${args.slice(0, 3).join(" ")} ...`);
|
|
224
|
+
this.process = spawn("claude", args, {
|
|
225
|
+
cwd: this.worktreePath,
|
|
226
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
227
|
+
});
|
|
228
|
+
let stdout = "";
|
|
229
|
+
let stderr = "";
|
|
230
|
+
this.process.stdout?.on("data", (data) => {
|
|
231
|
+
const text = data.toString();
|
|
232
|
+
stdout += text;
|
|
233
|
+
for (const line of text.split("\n")) {
|
|
234
|
+
if (line.trim()) {
|
|
235
|
+
log.debug(this.tag, `claude> ${line.slice(0, 200)}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
this.process.stderr?.on("data", (data) => {
|
|
240
|
+
stderr += data.toString();
|
|
241
|
+
});
|
|
242
|
+
this.process.on("error", (err) => {
|
|
243
|
+
reject(new Error(`Failed to spawn claude: ${err.message}`));
|
|
244
|
+
});
|
|
245
|
+
this.process.on("close", (code) => {
|
|
246
|
+
this.process = null;
|
|
247
|
+
if (this.aborted) {
|
|
248
|
+
resolve(stdout);
|
|
249
|
+
}
|
|
250
|
+
else if (code === 0) {
|
|
251
|
+
resolve(stdout);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
reject(new Error(`claude exited with code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`));
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
waitForExit(timeout) {
|
|
260
|
+
return new Promise((resolve) => {
|
|
261
|
+
if (!this.process || this.process.killed) {
|
|
262
|
+
resolve(true);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const timer = setTimeout(() => {
|
|
266
|
+
resolve(false);
|
|
267
|
+
}, timeout);
|
|
268
|
+
this.process.once("close", () => {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
resolve(true);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
killDevServer() {
|
|
275
|
+
if (this.devServerProcess && !this.devServerProcess.killed) {
|
|
276
|
+
this.devServerProcess.kill("SIGTERM");
|
|
277
|
+
this.devServerProcess = null;
|
|
278
|
+
log.debug(this.tag, "Killed dev server");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
cleanup() {
|
|
282
|
+
if (this.timeoutTimer) {
|
|
283
|
+
clearTimeout(this.timeoutTimer);
|
|
284
|
+
this.timeoutTimer = null;
|
|
285
|
+
}
|
|
286
|
+
this.killDevServer();
|
|
287
|
+
// Clean up worktree on error
|
|
288
|
+
if (this.worktreePath && this.state === "error") {
|
|
289
|
+
try {
|
|
290
|
+
cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
log.warn(this.tag, "Failed to cleanup review worktree");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
this.process = null;
|
|
297
|
+
this.cardId = null;
|
|
298
|
+
this.branchName = null;
|
|
299
|
+
this.worktreePath = null;
|
|
300
|
+
this.startedAt = null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkout an existing remote branch into a worktree for review.
|
|
3
|
+
* Unlike createWorktree() which creates a new branch, this checks out
|
|
4
|
+
* an existing branch that was pushed by the implementation worker.
|
|
5
|
+
*/
|
|
6
|
+
export declare function checkoutExistingBranch(basePath: string, branchName: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Extract branch name from card description's completion summary.
|
|
9
|
+
* Looks for: Branch: `agent/...`
|
|
10
|
+
* Validates that the extracted name contains only safe characters.
|
|
11
|
+
*/
|
|
12
|
+
export declare function extractBranchFromDescription(description: string | null | undefined): string | null;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { log } from "./log.js";
|
|
5
|
+
import { installCommand } from "./pm.js";
|
|
6
|
+
import { cleanupWorktree } from "./worktree.js";
|
|
7
|
+
const TAG = "review-worktree";
|
|
8
|
+
/**
|
|
9
|
+
* Checkout an existing remote branch into a worktree for review.
|
|
10
|
+
* Unlike createWorktree() which creates a new branch, this checks out
|
|
11
|
+
* an existing branch that was pushed by the implementation worker.
|
|
12
|
+
*/
|
|
13
|
+
export function checkoutExistingBranch(basePath, branchName) {
|
|
14
|
+
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
15
|
+
encoding: "utf-8",
|
|
16
|
+
}).trim();
|
|
17
|
+
const worktreeDir = resolve(repoRoot, basePath, `review-${branchName}`);
|
|
18
|
+
if (existsSync(worktreeDir)) {
|
|
19
|
+
log.warn(TAG, `Review worktree already exists at ${worktreeDir}, cleaning up`);
|
|
20
|
+
cleanupWorktree(worktreeDir);
|
|
21
|
+
}
|
|
22
|
+
// Fetch latest to ensure we have the remote branch
|
|
23
|
+
try {
|
|
24
|
+
execFileSync("git", ["fetch", "origin", branchName], {
|
|
25
|
+
cwd: repoRoot,
|
|
26
|
+
stdio: "pipe",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error(`Failed to fetch remote branch: ${branchName}`);
|
|
31
|
+
}
|
|
32
|
+
// Delete stale local branch if it exists
|
|
33
|
+
try {
|
|
34
|
+
execFileSync("git", ["branch", "-D", branchName], {
|
|
35
|
+
cwd: repoRoot,
|
|
36
|
+
stdio: "pipe",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Branch doesn't exist locally — that's fine
|
|
41
|
+
}
|
|
42
|
+
// Create worktree from the remote branch
|
|
43
|
+
log.info(TAG, `Creating review worktree: ${worktreeDir} (branch: ${branchName})`);
|
|
44
|
+
execFileSync("git", [
|
|
45
|
+
"worktree",
|
|
46
|
+
"add",
|
|
47
|
+
"--track",
|
|
48
|
+
"-b",
|
|
49
|
+
branchName,
|
|
50
|
+
worktreeDir,
|
|
51
|
+
`origin/${branchName}`,
|
|
52
|
+
], { cwd: repoRoot, stdio: "pipe" });
|
|
53
|
+
// Install dependencies
|
|
54
|
+
log.info(TAG, "Installing dependencies in review worktree...");
|
|
55
|
+
try {
|
|
56
|
+
execSync(installCommand(), {
|
|
57
|
+
cwd: worktreeDir,
|
|
58
|
+
stdio: "pipe",
|
|
59
|
+
timeout: 60_000,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
log.warn(TAG, "Install failed (may be fine if deps are hoisted)");
|
|
64
|
+
}
|
|
65
|
+
return worktreeDir;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Extract branch name from card description's completion summary.
|
|
69
|
+
* Looks for: Branch: `agent/...`
|
|
70
|
+
* Validates that the extracted name contains only safe characters.
|
|
71
|
+
*/
|
|
72
|
+
export function extractBranchFromDescription(description) {
|
|
73
|
+
if (!description)
|
|
74
|
+
return null;
|
|
75
|
+
const match = description.match(/Branch:\s*`([^`]+)`/);
|
|
76
|
+
const branch = match?.[1] ?? null;
|
|
77
|
+
// Validate branch name contains only safe git ref characters
|
|
78
|
+
if (branch && !/^[a-zA-Z0-9/_.-]+$/.test(branch)) {
|
|
79
|
+
log.warn(TAG, `Extracted branch name contains unsafe characters: ${branch}`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return branch;
|
|
83
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import type { Readable } from "node:stream";
|
|
3
|
+
export interface StreamParserEvents {
|
|
4
|
+
tool_start: [name: string, input: unknown];
|
|
5
|
+
tool_end: [name: string, toolUseId: string];
|
|
6
|
+
text: [content: string];
|
|
7
|
+
result: [stopReason: string];
|
|
8
|
+
error: [msg: string];
|
|
9
|
+
}
|
|
10
|
+
export declare class StreamParser extends EventEmitter<StreamParserEvents> {
|
|
11
|
+
private buffer;
|
|
12
|
+
private finalText;
|
|
13
|
+
get outputText(): string;
|
|
14
|
+
/**
|
|
15
|
+
* Pipe a readable stream (Claude CLI stdout) through the parser.
|
|
16
|
+
* Returns when the stream ends.
|
|
17
|
+
*/
|
|
18
|
+
pipe(stream: Readable): void;
|
|
19
|
+
private flush;
|
|
20
|
+
private parseLine;
|
|
21
|
+
private handleMessage;
|
|
22
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { log } from "./log.js";
|
|
3
|
+
const TAG = "stream-parser";
|
|
4
|
+
export class StreamParser extends EventEmitter {
|
|
5
|
+
buffer = "";
|
|
6
|
+
finalText = "";
|
|
7
|
+
get outputText() {
|
|
8
|
+
return this.finalText;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Pipe a readable stream (Claude CLI stdout) through the parser.
|
|
12
|
+
* Returns when the stream ends.
|
|
13
|
+
*/
|
|
14
|
+
pipe(stream) {
|
|
15
|
+
stream.on("data", (chunk) => {
|
|
16
|
+
this.buffer += chunk.toString();
|
|
17
|
+
this.flush();
|
|
18
|
+
});
|
|
19
|
+
stream.on("end", () => {
|
|
20
|
+
// Process any remaining buffer
|
|
21
|
+
if (this.buffer.trim()) {
|
|
22
|
+
this.flush();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
flush() {
|
|
27
|
+
const lines = this.buffer.split("\n");
|
|
28
|
+
// Keep incomplete last line in buffer
|
|
29
|
+
this.buffer = lines.pop() ?? "";
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
const trimmed = line.trim();
|
|
32
|
+
if (!trimmed)
|
|
33
|
+
continue;
|
|
34
|
+
this.parseLine(trimmed);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
parseLine(line) {
|
|
38
|
+
let msg;
|
|
39
|
+
try {
|
|
40
|
+
msg = JSON.parse(line);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Not valid JSON — skip silently (could be stray output)
|
|
44
|
+
log.debug(TAG, `Non-JSON line: ${line.slice(0, 100)}`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
this.handleMessage(msg);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
52
|
+
log.warn(TAG, `Error handling stream event: ${errMsg}`);
|
|
53
|
+
this.emit("error", errMsg);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
handleMessage(msg) {
|
|
57
|
+
switch (msg.type) {
|
|
58
|
+
case "assistant": {
|
|
59
|
+
if (msg.subtype === "tool_use" && msg.tool_name) {
|
|
60
|
+
this.emit("tool_start", msg.tool_name, msg.input);
|
|
61
|
+
}
|
|
62
|
+
else if (msg.subtype === "text" && msg.content) {
|
|
63
|
+
this.finalText += msg.content;
|
|
64
|
+
this.emit("text", msg.content);
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case "tool_result": {
|
|
69
|
+
if (msg.tool_use_id && msg.tool_name) {
|
|
70
|
+
this.emit("tool_end", msg.tool_name, msg.tool_use_id);
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "result": {
|
|
75
|
+
this.emit("result", msg.stop_reason ?? "unknown");
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
// Ignore system, ping, cost_update, etc.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Card, Column, Label, Subtask } from "@harmony/shared";
|
|
2
|
+
export type WorkMode = "implement" | "review";
|
|
3
|
+
export interface AgentConfig {
|
|
4
|
+
poolSize: number;
|
|
5
|
+
maxTimeout: number;
|
|
6
|
+
pickupColumns: string[];
|
|
7
|
+
priorityLabels: Record<string, number>;
|
|
8
|
+
columnBoost: boolean;
|
|
9
|
+
completion: {
|
|
10
|
+
createPR: boolean;
|
|
11
|
+
moveToColumn: string;
|
|
12
|
+
postSummary: boolean;
|
|
13
|
+
};
|
|
14
|
+
claude: {
|
|
15
|
+
model: string;
|
|
16
|
+
maxTurns: number;
|
|
17
|
+
additionalArgs: string[];
|
|
18
|
+
};
|
|
19
|
+
worktree: {
|
|
20
|
+
basePath: string;
|
|
21
|
+
baseBranch: string;
|
|
22
|
+
};
|
|
23
|
+
verification: {
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
build: boolean;
|
|
26
|
+
lint: boolean;
|
|
27
|
+
autoFix: boolean;
|
|
28
|
+
maxFixAttempts: number;
|
|
29
|
+
deepReview: boolean;
|
|
30
|
+
devServerBasePort: number;
|
|
31
|
+
timeout: number;
|
|
32
|
+
failColumn: string;
|
|
33
|
+
};
|
|
34
|
+
review: {
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
pickupColumns: string[];
|
|
37
|
+
moveToColumn: string;
|
|
38
|
+
failColumn: string;
|
|
39
|
+
devServerPort: number;
|
|
40
|
+
maxTimeout: number;
|
|
41
|
+
postFindings: boolean;
|
|
42
|
+
maxReviewCycles: number;
|
|
43
|
+
createPR: boolean;
|
|
44
|
+
approvedLabel: string;
|
|
45
|
+
approvedLabelColor: string;
|
|
46
|
+
mergeMonitor: boolean;
|
|
47
|
+
mergedLabel: string;
|
|
48
|
+
mergedLabelColor: string;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export declare const DEFAULT_AGENT_CONFIG: AgentConfig;
|
|
52
|
+
export declare const AGENT_NAME = "Harmony Agent";
|
|
53
|
+
export declare function agentIdentifier(workerId: number): string;
|
|
54
|
+
export type ProgressPhase = "exploring" | "implementing" | "testing" | "committing" | "finishing";
|
|
55
|
+
export type WorkerState = "idle" | "preparing" | "running" | "completing" | "verifying" | "cancelling" | "error";
|
|
56
|
+
export interface QueueItem {
|
|
57
|
+
cardId: string;
|
|
58
|
+
shortId: number;
|
|
59
|
+
title: string;
|
|
60
|
+
priority: number;
|
|
61
|
+
enqueuedAt: number;
|
|
62
|
+
mode: WorkMode;
|
|
63
|
+
}
|
|
64
|
+
export interface EnrichedCard {
|
|
65
|
+
card: Card;
|
|
66
|
+
column: Column;
|
|
67
|
+
labels: Label[];
|
|
68
|
+
subtasks: Subtask[];
|
|
69
|
+
mode: WorkMode;
|
|
70
|
+
}
|
|
71
|
+
export interface RealtimeCredentials {
|
|
72
|
+
supabaseUrl: string;
|
|
73
|
+
supabaseAnonKey: string;
|
|
74
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const DEFAULT_AGENT_CONFIG = {
|
|
2
|
+
poolSize: 1,
|
|
3
|
+
maxTimeout: 1_800_000, // 30 minutes
|
|
4
|
+
pickupColumns: ["To Do"],
|
|
5
|
+
priorityLabels: { urgent: 100, critical: 90, bug: 50 },
|
|
6
|
+
columnBoost: true,
|
|
7
|
+
completion: {
|
|
8
|
+
createPR: false,
|
|
9
|
+
moveToColumn: "Review",
|
|
10
|
+
postSummary: true,
|
|
11
|
+
},
|
|
12
|
+
claude: {
|
|
13
|
+
model: "opus",
|
|
14
|
+
maxTurns: 200,
|
|
15
|
+
additionalArgs: [],
|
|
16
|
+
},
|
|
17
|
+
worktree: {
|
|
18
|
+
basePath: ".harmony-worktrees",
|
|
19
|
+
baseBranch: "main",
|
|
20
|
+
},
|
|
21
|
+
verification: {
|
|
22
|
+
enabled: true,
|
|
23
|
+
build: true,
|
|
24
|
+
lint: true,
|
|
25
|
+
autoFix: true,
|
|
26
|
+
maxFixAttempts: 1,
|
|
27
|
+
deepReview: false,
|
|
28
|
+
devServerBasePort: 4200,
|
|
29
|
+
timeout: 120_000,
|
|
30
|
+
failColumn: "Needs Fix",
|
|
31
|
+
},
|
|
32
|
+
review: {
|
|
33
|
+
enabled: true,
|
|
34
|
+
pickupColumns: ["Review"],
|
|
35
|
+
moveToColumn: "Done",
|
|
36
|
+
failColumn: "To Do",
|
|
37
|
+
devServerPort: 4300,
|
|
38
|
+
maxTimeout: 600_000,
|
|
39
|
+
postFindings: true,
|
|
40
|
+
maxReviewCycles: 3,
|
|
41
|
+
createPR: true,
|
|
42
|
+
approvedLabel: "Ready to Merge",
|
|
43
|
+
approvedLabelColor: "#22c55e",
|
|
44
|
+
mergeMonitor: true,
|
|
45
|
+
mergedLabel: "Merged",
|
|
46
|
+
mergedLabelColor: "#6366f1",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
// ============ AGENT IDENTITY ============
|
|
50
|
+
export const AGENT_NAME = "Harmony Agent";
|
|
51
|
+
export function agentIdentifier(workerId) {
|
|
52
|
+
return `harmony-daemon-${workerId}`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ChildProcess } from "node:child_process";
|
|
2
|
+
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
3
|
+
import type { AgentConfig } from "./types.js";
|
|
4
|
+
export interface VerificationResult {
|
|
5
|
+
passed: boolean;
|
|
6
|
+
buildErrors: string[];
|
|
7
|
+
lintWarnings: string[];
|
|
8
|
+
reviewFindings: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function runVerification(worktreePath: string, config: AgentConfig, workerId: number): Promise<VerificationResult>;
|
|
11
|
+
export declare function runBuild(worktreePath: string, timeout: number): string[];
|
|
12
|
+
export declare function runLint(worktreePath: string, timeout: number): string[];
|
|
13
|
+
export declare function runDeepReview(worktreePath: string, config: AgentConfig, workerId: number): Promise<string[]>;
|
|
14
|
+
export declare function attemptAutoFix(worktreePath: string, config: AgentConfig, errors: string[]): void;
|
|
15
|
+
export declare function reportFindings(client: HarmonyApiClient, cardId: string, result: VerificationResult): Promise<void>;
|
|
16
|
+
export declare function waitForDevServer(proc: ChildProcess, timeout: number): Promise<void>;
|