@gethmy/agent 1.0.0 → 1.0.2
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 +7 -6
- package/dist/board-helpers.d.ts +31 -0
- package/dist/board-helpers.js +150 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +2 -11761
- package/dist/completion.d.ts +14 -0
- package/dist/completion.js +142 -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 +169 -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 +167 -0
- package/dist/pm.d.ts +14 -0
- package/dist/pm.js +63 -0
- package/dist/pool.d.ts +40 -0
- package/dist/pool.js +157 -0
- package/dist/progress-tracker.d.ts +64 -0
- package/dist/progress-tracker.js +361 -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 +114 -0
- package/dist/review-completion.d.ts +31 -0
- package/dist/review-completion.js +253 -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 +103 -0
- package/dist/review-worker.d.ts +46 -0
- package/dist/review-worker.js +437 -0
- package/dist/review-worktree.d.ts +12 -0
- package/dist/review-worktree.js +83 -0
- package/dist/stream-parser.d.ts +31 -0
- package/dist/stream-parser.js +95 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.js +56 -0
- package/dist/verification.d.ts +16 -0
- package/dist/verification.js +251 -0
- package/dist/watcher.d.ts +27 -0
- package/dist/watcher.js +74 -0
- package/dist/worker.d.ts +43 -0
- package/dist/worker.js +327 -0
- package/dist/worktree.d.ts +13 -0
- package/dist/worktree.js +115 -0
- package/package.json +8 -7
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { log } from "./log.js";
|
|
2
|
+
import { AGENT_NAME, agentIdentifier } from "./types.js";
|
|
3
|
+
const TAG = "progress-tracker";
|
|
4
|
+
const THROTTLE_MS = 5_000;
|
|
5
|
+
const HEARTBEAT_MS = 60_000;
|
|
6
|
+
const MAX_TASK_LENGTH = 120;
|
|
7
|
+
const MAX_RECENT_ACTIONS = 5;
|
|
8
|
+
// Hoisted regexes — avoids recompilation on every call
|
|
9
|
+
const SENTENCE_SPLIT = /\.\s|\n/;
|
|
10
|
+
const ACTION_PREFIX = /^(Let me|I'll|I need to|Now|First|Next|Looking|Checking|Creating|Adding|Updating|Fixing|Refactoring|Moving|The |This )/i;
|
|
11
|
+
const GIT_COMMIT_RE = /\bgit\s+commit\b/;
|
|
12
|
+
const BUILD_CMD_RE = /\b(test|build|lint|check|tsc|vitest|jest|(?:bun|npm|pnpm|yarn) run (?:build|lint))\b/;
|
|
13
|
+
const PHASES = {
|
|
14
|
+
exploring: { min: 10, max: 25, label: "Exploring" },
|
|
15
|
+
implementing: { min: 25, max: 55, label: "Implementing" },
|
|
16
|
+
testing: { min: 55, max: 65, label: "Testing" },
|
|
17
|
+
committing: { min: 65, max: 70, label: "Committing" },
|
|
18
|
+
finishing: { min: 70, max: 75, label: "Finalizing" },
|
|
19
|
+
};
|
|
20
|
+
const PHASE_ORDER = {
|
|
21
|
+
exploring: 0,
|
|
22
|
+
implementing: 1,
|
|
23
|
+
testing: 2,
|
|
24
|
+
committing: 3,
|
|
25
|
+
finishing: 4,
|
|
26
|
+
};
|
|
27
|
+
// Tools that indicate implementation
|
|
28
|
+
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
29
|
+
function truncate(str, max) {
|
|
30
|
+
return str.length > max ? `${str.slice(0, max - 3)}...` : str;
|
|
31
|
+
}
|
|
32
|
+
// Map file-based tools to their display verbs
|
|
33
|
+
const FILE_TOOL_VERBS = {
|
|
34
|
+
Read: ["Reading", "file_path"],
|
|
35
|
+
Edit: ["Editing", "file_path"],
|
|
36
|
+
MultiEdit: ["Editing", "file_path"],
|
|
37
|
+
Write: ["Writing", "file_path"],
|
|
38
|
+
NotebookEdit: ["Editing notebook", "notebook_path"],
|
|
39
|
+
};
|
|
40
|
+
export class ProgressTracker {
|
|
41
|
+
client;
|
|
42
|
+
cardId;
|
|
43
|
+
workerId;
|
|
44
|
+
phase = "exploring";
|
|
45
|
+
progress = 10;
|
|
46
|
+
toolCallCount = 0;
|
|
47
|
+
hasEdited = false;
|
|
48
|
+
lastUpdateAt = 0;
|
|
49
|
+
pendingUpdate = null;
|
|
50
|
+
pendingTask = "";
|
|
51
|
+
heartbeatTimer = null;
|
|
52
|
+
stopped = false;
|
|
53
|
+
// Rich task tracking
|
|
54
|
+
lastAction = "";
|
|
55
|
+
// Subtask tracking
|
|
56
|
+
subtaskTotal;
|
|
57
|
+
subtaskCompleted;
|
|
58
|
+
subtaskMode;
|
|
59
|
+
// Rich activity tracking
|
|
60
|
+
filesEdited = new Set();
|
|
61
|
+
filesRead = new Set();
|
|
62
|
+
lastCost = null;
|
|
63
|
+
recentActions = [];
|
|
64
|
+
constructor(client, cardId, workerId, subtasks) {
|
|
65
|
+
this.client = client;
|
|
66
|
+
this.cardId = cardId;
|
|
67
|
+
this.workerId = workerId;
|
|
68
|
+
this.subtaskTotal = subtasks.length;
|
|
69
|
+
this.subtaskCompleted = subtasks.filter((s) => s.completed).length;
|
|
70
|
+
this.subtaskMode = subtasks.length > 0;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Wire up the parser events and start the heartbeat.
|
|
74
|
+
*/
|
|
75
|
+
attach(parser) {
|
|
76
|
+
parser.on("tool_start", (name, input) => {
|
|
77
|
+
this.onToolStart(name, input);
|
|
78
|
+
});
|
|
79
|
+
parser.on("tool_end", (name, _id, content) => {
|
|
80
|
+
this.onToolEnd(name, content);
|
|
81
|
+
});
|
|
82
|
+
parser.on("text", (content) => {
|
|
83
|
+
this.onText(content);
|
|
84
|
+
});
|
|
85
|
+
parser.on("cost_update", (cost) => {
|
|
86
|
+
this.lastCost = cost;
|
|
87
|
+
});
|
|
88
|
+
this.startHeartbeat();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Stop all timers and flush any pending update.
|
|
92
|
+
*/
|
|
93
|
+
stop() {
|
|
94
|
+
this.stopped = true;
|
|
95
|
+
if (this.pendingUpdate) {
|
|
96
|
+
clearTimeout(this.pendingUpdate);
|
|
97
|
+
this.pendingUpdate = null;
|
|
98
|
+
}
|
|
99
|
+
if (this.heartbeatTimer) {
|
|
100
|
+
clearTimeout(this.heartbeatTimer);
|
|
101
|
+
this.heartbeatTimer = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Get a summary of the session stats. */
|
|
105
|
+
get stats() {
|
|
106
|
+
return {
|
|
107
|
+
filesEdited: this.filesEdited.size,
|
|
108
|
+
filesRead: this.filesRead.size,
|
|
109
|
+
toolCalls: this.toolCallCount,
|
|
110
|
+
cost: this.lastCost,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
onToolStart(name, input) {
|
|
114
|
+
this.toolCallCount++;
|
|
115
|
+
log.debug(TAG, `Tool: ${name} (count: ${this.toolCallCount}, phase: ${this.phase})`);
|
|
116
|
+
// Track files
|
|
117
|
+
const filePath = this.extractString(input, "file_path");
|
|
118
|
+
if (filePath) {
|
|
119
|
+
if (EDIT_TOOLS.has(name)) {
|
|
120
|
+
this.filesEdited.add(filePath);
|
|
121
|
+
}
|
|
122
|
+
else if (name === "Read" || name === "Glob" || name === "Grep") {
|
|
123
|
+
this.filesRead.add(filePath);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Detect phase transitions
|
|
127
|
+
if (!this.hasEdited && EDIT_TOOLS.has(name)) {
|
|
128
|
+
this.hasEdited = true;
|
|
129
|
+
this.transitionTo("implementing");
|
|
130
|
+
}
|
|
131
|
+
else if (this.hasEdited && name === "Bash") {
|
|
132
|
+
const cmd = this.extractString(input, "command");
|
|
133
|
+
if (cmd && GIT_COMMIT_RE.test(cmd)) {
|
|
134
|
+
this.transitionTo("committing");
|
|
135
|
+
}
|
|
136
|
+
else if (cmd && BUILD_CMD_RE.test(cmd)) {
|
|
137
|
+
this.transitionTo("testing");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (name === "mcp__harmony__harmony_end_agent_session") {
|
|
141
|
+
this.transitionTo("finishing");
|
|
142
|
+
}
|
|
143
|
+
// Handle subtask toggling — override heuristic progress
|
|
144
|
+
if (name === "mcp__harmony__harmony_toggle_subtask" && this.subtaskMode) {
|
|
145
|
+
const val = this.extractString(input, "completed");
|
|
146
|
+
const completing = val === null || val === "true";
|
|
147
|
+
if (completing) {
|
|
148
|
+
this.subtaskCompleted = Math.min(this.subtaskCompleted + 1, this.subtaskTotal);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
this.subtaskCompleted = Math.max(this.subtaskCompleted - 1, 0);
|
|
152
|
+
}
|
|
153
|
+
const subtaskProgress = Math.round(10 + (this.subtaskCompleted / this.subtaskTotal) * 60);
|
|
154
|
+
this.progress = Math.max(this.progress, subtaskProgress);
|
|
155
|
+
this.scheduleUpdate(`Completed subtask ${this.subtaskCompleted}/${this.subtaskTotal}`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// Build rich action description and increment progress
|
|
159
|
+
const action = this.describeToolAction(name, input);
|
|
160
|
+
if (action) {
|
|
161
|
+
this.lastAction = action;
|
|
162
|
+
this.pushRecentAction(action);
|
|
163
|
+
}
|
|
164
|
+
this.incrementProgress();
|
|
165
|
+
}
|
|
166
|
+
onToolEnd(name, content) {
|
|
167
|
+
// Detect build/test failures from Bash results
|
|
168
|
+
if (name === "Bash" && content && this.phase === "testing") {
|
|
169
|
+
const lower = content.slice(-500).toLowerCase();
|
|
170
|
+
if (lower.includes("error") &&
|
|
171
|
+
(lower.includes("build failed") ||
|
|
172
|
+
lower.includes("failed to compile") ||
|
|
173
|
+
lower.includes("exit code 1"))) {
|
|
174
|
+
this.lastAction = "Build failed — fixing errors";
|
|
175
|
+
this.scheduleUpdate(this.lastAction);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Reset heartbeat on any activity
|
|
179
|
+
this.startHeartbeat();
|
|
180
|
+
}
|
|
181
|
+
onText(content) {
|
|
182
|
+
// Capture brief reasoning snippets from Claude's text output.
|
|
183
|
+
// Only update if the text looks like a meaningful status line
|
|
184
|
+
// (skip very short fragments from streaming).
|
|
185
|
+
const trimmed = content.trim();
|
|
186
|
+
if (trimmed.length < 10)
|
|
187
|
+
return;
|
|
188
|
+
// Extract first sentence or line as a brief description
|
|
189
|
+
const end = trimmed.search(SENTENCE_SPLIT);
|
|
190
|
+
const firstLine = (end === -1 ? trimmed : trimmed.slice(0, end)).trim();
|
|
191
|
+
if (firstLine.length >= 10 && firstLine.length <= 200) {
|
|
192
|
+
if (ACTION_PREFIX.test(firstLine)) {
|
|
193
|
+
this.lastAction = truncate(firstLine, MAX_TASK_LENGTH);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
transitionTo(newPhase) {
|
|
198
|
+
if (PHASE_ORDER[newPhase] <= PHASE_ORDER[this.phase])
|
|
199
|
+
return;
|
|
200
|
+
log.info(TAG, `Phase: ${this.phase} → ${newPhase}`);
|
|
201
|
+
this.phase = newPhase;
|
|
202
|
+
this.progress = Math.max(this.progress, PHASES[newPhase].min);
|
|
203
|
+
// Reset stale action from prior phase; new phase starts with its own label
|
|
204
|
+
this.lastAction = "";
|
|
205
|
+
this.scheduleUpdate(PHASES[newPhase].label);
|
|
206
|
+
}
|
|
207
|
+
incrementProgress() {
|
|
208
|
+
const config = PHASES[this.phase];
|
|
209
|
+
const range = config.max - config.min;
|
|
210
|
+
const step = Math.max(1, Math.round(range / 12));
|
|
211
|
+
const newProgress = Math.min(this.progress + step, config.max);
|
|
212
|
+
if (newProgress > this.progress) {
|
|
213
|
+
this.progress = newProgress;
|
|
214
|
+
this.scheduleUpdate(this.currentTaskLabel());
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
currentTaskLabel() {
|
|
218
|
+
if (this.lastAction)
|
|
219
|
+
return this.lastAction;
|
|
220
|
+
// Include file count context when available
|
|
221
|
+
const edited = this.filesEdited.size;
|
|
222
|
+
if (edited > 0) {
|
|
223
|
+
return `${PHASES[this.phase].label} (${edited} file${edited > 1 ? "s" : ""} modified)`;
|
|
224
|
+
}
|
|
225
|
+
return PHASES[this.phase].label;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Build a human-readable description of what a tool call is doing.
|
|
229
|
+
*/
|
|
230
|
+
describeToolAction(name, input) {
|
|
231
|
+
// File-based tools: Read, Edit, MultiEdit, Write, NotebookEdit
|
|
232
|
+
const fileTool = FILE_TOOL_VERBS[name];
|
|
233
|
+
if (fileTool) {
|
|
234
|
+
const [verb, key] = fileTool;
|
|
235
|
+
const fp = this.extractString(input, key);
|
|
236
|
+
return fp ? `${verb} ${this.shortPath(fp)}` : verb;
|
|
237
|
+
}
|
|
238
|
+
switch (name) {
|
|
239
|
+
case "Glob": {
|
|
240
|
+
const pattern = this.extractString(input, "pattern");
|
|
241
|
+
return pattern ? `Searching for ${pattern}` : "Searching files";
|
|
242
|
+
}
|
|
243
|
+
case "Grep": {
|
|
244
|
+
const pattern = this.extractString(input, "pattern");
|
|
245
|
+
return pattern
|
|
246
|
+
? `Searching for "${truncate(pattern, 40)}"`
|
|
247
|
+
: "Searching code";
|
|
248
|
+
}
|
|
249
|
+
case "Bash": {
|
|
250
|
+
const cmd = this.extractString(input, "command");
|
|
251
|
+
return cmd
|
|
252
|
+
? `Running: ${truncate(cmd.split("\n")[0], 80)}`
|
|
253
|
+
: "Running command";
|
|
254
|
+
}
|
|
255
|
+
case "Agent": {
|
|
256
|
+
const desc = this.extractString(input, "description");
|
|
257
|
+
return desc
|
|
258
|
+
? `Sub-agent: ${truncate(desc, 60)}`
|
|
259
|
+
: "Delegating to sub-agent";
|
|
260
|
+
}
|
|
261
|
+
default: {
|
|
262
|
+
if (name.startsWith("mcp__harmony__harmony_")) {
|
|
263
|
+
const toolName = name
|
|
264
|
+
.replace("mcp__harmony__harmony_", "")
|
|
265
|
+
.replace(/_/g, " ");
|
|
266
|
+
return `Harmony: ${toolName}`;
|
|
267
|
+
}
|
|
268
|
+
if (name.startsWith("mcp__")) {
|
|
269
|
+
return `Tool: ${name.split("__").pop()?.replace(/_/g, " ") ?? name}`;
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Strip absolute paths to show only meaningful segments from src/ or packages/.
|
|
277
|
+
*/
|
|
278
|
+
shortPath(filePath) {
|
|
279
|
+
const parts = filePath.split("/");
|
|
280
|
+
const srcIdx = parts.lastIndexOf("src");
|
|
281
|
+
const pkgIdx = parts.lastIndexOf("packages");
|
|
282
|
+
const anchor = Math.max(srcIdx, pkgIdx);
|
|
283
|
+
if (anchor >= 0) {
|
|
284
|
+
return parts.slice(anchor).join("/");
|
|
285
|
+
}
|
|
286
|
+
return parts.slice(-3).join("/");
|
|
287
|
+
}
|
|
288
|
+
scheduleUpdate(currentTask) {
|
|
289
|
+
if (this.stopped)
|
|
290
|
+
return;
|
|
291
|
+
const now = Date.now();
|
|
292
|
+
const elapsed = now - this.lastUpdateAt;
|
|
293
|
+
// Always track latest task so throttled sends use the freshest label
|
|
294
|
+
this.pendingTask = currentTask;
|
|
295
|
+
if (elapsed >= THROTTLE_MS) {
|
|
296
|
+
this.sendUpdate(this.pendingTask);
|
|
297
|
+
}
|
|
298
|
+
else if (!this.pendingUpdate) {
|
|
299
|
+
const delay = THROTTLE_MS - elapsed;
|
|
300
|
+
this.pendingUpdate = setTimeout(() => {
|
|
301
|
+
this.pendingUpdate = null;
|
|
302
|
+
if (!this.stopped) {
|
|
303
|
+
// Send the latest task, not the one captured at schedule time
|
|
304
|
+
this.sendUpdate(this.pendingTask);
|
|
305
|
+
}
|
|
306
|
+
}, delay);
|
|
307
|
+
}
|
|
308
|
+
// If there's already a pending update, pendingTask is now updated — it will use the fresh value
|
|
309
|
+
}
|
|
310
|
+
pushRecentAction(action) {
|
|
311
|
+
this.recentActions.push({
|
|
312
|
+
action,
|
|
313
|
+
ts: new Date().toISOString(),
|
|
314
|
+
});
|
|
315
|
+
if (this.recentActions.length > MAX_RECENT_ACTIONS) {
|
|
316
|
+
this.recentActions.shift();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
sendUpdate(currentTask) {
|
|
320
|
+
this.lastUpdateAt = Date.now();
|
|
321
|
+
log.debug(TAG, `Progress: ${this.progress}% — ${currentTask}`);
|
|
322
|
+
this.client
|
|
323
|
+
.updateAgentProgress(this.cardId, {
|
|
324
|
+
agentIdentifier: agentIdentifier(this.workerId),
|
|
325
|
+
agentName: AGENT_NAME,
|
|
326
|
+
status: "working",
|
|
327
|
+
currentTask: truncate(currentTask, MAX_TASK_LENGTH),
|
|
328
|
+
progressPercent: this.progress,
|
|
329
|
+
phase: this.phase,
|
|
330
|
+
filesChanged: this.filesEdited.size,
|
|
331
|
+
costCents: Math.round((this.lastCost?.totalCostUsd ?? 0) * 100),
|
|
332
|
+
recentActions: this.recentActions,
|
|
333
|
+
})
|
|
334
|
+
.catch((err) => {
|
|
335
|
+
log.warn(TAG, `Failed to send progress update: ${err}`);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
startHeartbeat() {
|
|
339
|
+
if (this.heartbeatTimer) {
|
|
340
|
+
clearTimeout(this.heartbeatTimer);
|
|
341
|
+
}
|
|
342
|
+
this.heartbeatTimer = setTimeout(() => {
|
|
343
|
+
if (!this.stopped) {
|
|
344
|
+
const task = this.lastAction
|
|
345
|
+
? `Still working — ${this.lastAction}`
|
|
346
|
+
: "Still working...";
|
|
347
|
+
this.sendUpdate(truncate(task, MAX_TASK_LENGTH));
|
|
348
|
+
this.startHeartbeat();
|
|
349
|
+
}
|
|
350
|
+
}, HEARTBEAT_MS);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Safely extract a string property from an unknown tool input.
|
|
354
|
+
*/
|
|
355
|
+
extractString(input, key) {
|
|
356
|
+
if (typeof input === "object" && input !== null && key in input) {
|
|
357
|
+
return String(input[key]);
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
package/dist/prompt.d.ts
ADDED
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the prompt that gets passed to Claude CLI for working on a card.
|
|
3
|
+
*/
|
|
4
|
+
export function buildPrompt(enriched, branchName, worktreePath) {
|
|
5
|
+
const { card, column, labels, subtasks } = enriched;
|
|
6
|
+
const labelStr = labels.length > 0 ? labels.map((l) => l.name).join(", ") : "none";
|
|
7
|
+
const subtaskStr = subtasks.length > 0
|
|
8
|
+
? subtasks
|
|
9
|
+
.map((s) => `- [${s.completed ? "x" : " "}] ${s.title}`)
|
|
10
|
+
.join("\n")
|
|
11
|
+
: "No subtasks defined.";
|
|
12
|
+
const description = card.description?.trim() || "No description provided.";
|
|
13
|
+
return `You are an AI agent working on a task from the Harmony project board.
|
|
14
|
+
|
|
15
|
+
## Card: #${card.short_id} - ${card.title}
|
|
16
|
+
**Labels**: ${labelStr}
|
|
17
|
+
**Column**: ${column.name}
|
|
18
|
+
**Priority**: ${card.priority}
|
|
19
|
+
|
|
20
|
+
## Description
|
|
21
|
+
${description}
|
|
22
|
+
|
|
23
|
+
## Subtasks
|
|
24
|
+
${subtaskStr}
|
|
25
|
+
|
|
26
|
+
## Instructions
|
|
27
|
+
1. Read the codebase and understand the context needed for this task
|
|
28
|
+
2. Report progress via harmony_update_agent_progress at key milestones:
|
|
29
|
+
- After reading codebase and forming a plan (~20%)
|
|
30
|
+
- After each major implementation step (~30-60%)
|
|
31
|
+
- After completing each subtask (also toggle via harmony_toggle_subtask)
|
|
32
|
+
- Before committing (~65%)
|
|
33
|
+
Include a brief currentTask description.
|
|
34
|
+
3. Implement the changes on branch \`${branchName}\`
|
|
35
|
+
4. Commit your work with clear, descriptive commit messages
|
|
36
|
+
5. When finished, call harmony_end_agent_session with status="completed"
|
|
37
|
+
|
|
38
|
+
You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
|
|
39
|
+
Do NOT push to main. All your work stays on \`${branchName}\`.`;
|
|
40
|
+
}
|
package/dist/queue.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Card, Column, Label } from "@harmony/shared";
|
|
2
|
+
import type { AgentConfig, QueueItem, WorkMode } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Priority queue for cards waiting to be worked on.
|
|
5
|
+
* Sorted by: label priority boost > column position boost > enqueue time (FIFO).
|
|
6
|
+
*/
|
|
7
|
+
export declare class PriorityQueue {
|
|
8
|
+
private config;
|
|
9
|
+
private items;
|
|
10
|
+
constructor(config: AgentConfig);
|
|
11
|
+
/**
|
|
12
|
+
* Calculate priority score for a card.
|
|
13
|
+
*/
|
|
14
|
+
scoreCard(_card: Card, column: Column, labels: Label[]): number;
|
|
15
|
+
/**
|
|
16
|
+
* Add a card to the queue. If already present, update its priority.
|
|
17
|
+
*/
|
|
18
|
+
enqueue(card: Card, column: Column, labels: Label[], mode?: WorkMode): void;
|
|
19
|
+
/**
|
|
20
|
+
* Remove and return the highest-priority item.
|
|
21
|
+
*/
|
|
22
|
+
dequeue(): QueueItem | null;
|
|
23
|
+
/**
|
|
24
|
+
* Remove a specific card from the queue.
|
|
25
|
+
*/
|
|
26
|
+
remove(cardId: string): QueueItem | null;
|
|
27
|
+
/**
|
|
28
|
+
* Check if a card is in the queue.
|
|
29
|
+
*/
|
|
30
|
+
has(cardId: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Get all queued card IDs.
|
|
33
|
+
*/
|
|
34
|
+
cardIds(): string[];
|
|
35
|
+
get length(): number;
|
|
36
|
+
peek(): QueueItem | null;
|
|
37
|
+
}
|
package/dist/queue.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { log } from "./log.js";
|
|
2
|
+
const TAG = "queue";
|
|
3
|
+
/**
|
|
4
|
+
* Priority queue for cards waiting to be worked on.
|
|
5
|
+
* Sorted by: label priority boost > column position boost > enqueue time (FIFO).
|
|
6
|
+
*/
|
|
7
|
+
export class PriorityQueue {
|
|
8
|
+
config;
|
|
9
|
+
items = [];
|
|
10
|
+
constructor(config) {
|
|
11
|
+
this.config = config;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Calculate priority score for a card.
|
|
15
|
+
*/
|
|
16
|
+
scoreCard(_card, column, labels) {
|
|
17
|
+
let score = 0;
|
|
18
|
+
// Label boost: highest matching label wins
|
|
19
|
+
for (const label of labels) {
|
|
20
|
+
const boost = this.config.priorityLabels[label.name.toLowerCase()] ?? 0;
|
|
21
|
+
if (boost > score)
|
|
22
|
+
score = boost;
|
|
23
|
+
}
|
|
24
|
+
// Column position boost: leftmost columns get higher priority
|
|
25
|
+
if (this.config.columnBoost) {
|
|
26
|
+
score += Math.max(0, 100 - column.position * 10);
|
|
27
|
+
}
|
|
28
|
+
return score;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Add a card to the queue. If already present, update its priority.
|
|
32
|
+
*/
|
|
33
|
+
enqueue(card, column, labels, mode = "implement") {
|
|
34
|
+
const existing = this.items.findIndex((i) => i.cardId === card.id);
|
|
35
|
+
if (existing !== -1) {
|
|
36
|
+
log.debug(TAG, `Card #${card.short_id} already queued, updating priority`);
|
|
37
|
+
this.items.splice(existing, 1);
|
|
38
|
+
}
|
|
39
|
+
const priority = this.scoreCard(card, column, labels);
|
|
40
|
+
const item = {
|
|
41
|
+
cardId: card.id,
|
|
42
|
+
shortId: card.short_id,
|
|
43
|
+
title: card.title,
|
|
44
|
+
priority,
|
|
45
|
+
enqueuedAt: Date.now(),
|
|
46
|
+
mode,
|
|
47
|
+
};
|
|
48
|
+
// Insert in sorted position (highest priority first, FIFO tiebreak)
|
|
49
|
+
let insertIdx = this.items.length;
|
|
50
|
+
for (let i = 0; i < this.items.length; i++) {
|
|
51
|
+
if (priority > this.items[i].priority ||
|
|
52
|
+
(priority === this.items[i].priority &&
|
|
53
|
+
item.enqueuedAt < this.items[i].enqueuedAt)) {
|
|
54
|
+
insertIdx = i;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
this.items.splice(insertIdx, 0, item);
|
|
59
|
+
log.info(TAG, `Enqueued #${card.short_id} "${card.title}" (priority=${priority}, pos=${insertIdx}, queue=${this.items.length})`);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Remove and return the highest-priority item.
|
|
63
|
+
*/
|
|
64
|
+
dequeue() {
|
|
65
|
+
return this.items.shift() ?? null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Remove a specific card from the queue.
|
|
69
|
+
*/
|
|
70
|
+
remove(cardId) {
|
|
71
|
+
const idx = this.items.findIndex((i) => i.cardId === cardId);
|
|
72
|
+
if (idx === -1)
|
|
73
|
+
return null;
|
|
74
|
+
const [item] = this.items.splice(idx, 1);
|
|
75
|
+
log.info(TAG, `Removed #${item.shortId} from queue`);
|
|
76
|
+
return item;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Check if a card is in the queue.
|
|
80
|
+
*/
|
|
81
|
+
has(cardId) {
|
|
82
|
+
return this.items.some((i) => i.cardId === cardId);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get all queued card IDs.
|
|
86
|
+
*/
|
|
87
|
+
cardIds() {
|
|
88
|
+
return this.items.map((i) => i.cardId);
|
|
89
|
+
}
|
|
90
|
+
get length() {
|
|
91
|
+
return this.items.length;
|
|
92
|
+
}
|
|
93
|
+
peek() {
|
|
94
|
+
return this.items[0] ?? null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
2
|
+
import type { Pool } from "./pool.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reconciliation heartbeat: polls the board every `intervalMs` to catch
|
|
5
|
+
* missed realtime events and sync state.
|
|
6
|
+
*/
|
|
7
|
+
export declare class Reconciler {
|
|
8
|
+
private client;
|
|
9
|
+
private pool;
|
|
10
|
+
private projectId;
|
|
11
|
+
private agentUserId;
|
|
12
|
+
private pickupColumns;
|
|
13
|
+
private reviewColumns;
|
|
14
|
+
private approvedLabel;
|
|
15
|
+
private intervalMs;
|
|
16
|
+
private timer;
|
|
17
|
+
constructor(client: HarmonyApiClient, pool: Pool, projectId: string, agentUserId: string, pickupColumns: string[], reviewColumns: string[], approvedLabel: string, intervalMs?: number);
|
|
18
|
+
start(): void;
|
|
19
|
+
stop(): void;
|
|
20
|
+
private tick;
|
|
21
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { buildLabelMap, hasLabel, resolveCardLabels } from "./board-helpers.js";
|
|
2
|
+
import { log } from "./log.js";
|
|
3
|
+
import { NEED_REVIEW_LABEL } from "./types.js";
|
|
4
|
+
const TAG = "reconcile";
|
|
5
|
+
/**
|
|
6
|
+
* Reconciliation heartbeat: polls the board every `intervalMs` to catch
|
|
7
|
+
* missed realtime events and sync state.
|
|
8
|
+
*/
|
|
9
|
+
export class Reconciler {
|
|
10
|
+
client;
|
|
11
|
+
pool;
|
|
12
|
+
projectId;
|
|
13
|
+
agentUserId;
|
|
14
|
+
pickupColumns;
|
|
15
|
+
reviewColumns;
|
|
16
|
+
approvedLabel;
|
|
17
|
+
intervalMs;
|
|
18
|
+
timer = null;
|
|
19
|
+
constructor(client, pool, projectId, agentUserId, pickupColumns, reviewColumns, approvedLabel, intervalMs = 60_000) {
|
|
20
|
+
this.client = client;
|
|
21
|
+
this.pool = pool;
|
|
22
|
+
this.projectId = projectId;
|
|
23
|
+
this.agentUserId = agentUserId;
|
|
24
|
+
this.pickupColumns = pickupColumns;
|
|
25
|
+
this.reviewColumns = reviewColumns;
|
|
26
|
+
this.approvedLabel = approvedLabel;
|
|
27
|
+
this.intervalMs = intervalMs;
|
|
28
|
+
}
|
|
29
|
+
start() {
|
|
30
|
+
log.info(TAG, `Heartbeat every ${this.intervalMs / 1000}s`);
|
|
31
|
+
// Run immediately, then on interval
|
|
32
|
+
this.tick();
|
|
33
|
+
this.timer = setInterval(() => this.tick(), this.intervalMs);
|
|
34
|
+
}
|
|
35
|
+
stop() {
|
|
36
|
+
if (this.timer) {
|
|
37
|
+
clearInterval(this.timer);
|
|
38
|
+
this.timer = null;
|
|
39
|
+
}
|
|
40
|
+
log.info(TAG, "Heartbeat stopped");
|
|
41
|
+
}
|
|
42
|
+
async tick() {
|
|
43
|
+
try {
|
|
44
|
+
const board = await this.client.getBoard(this.projectId);
|
|
45
|
+
const cards = (board.cards ?? []);
|
|
46
|
+
const columns = (board.columns ?? []);
|
|
47
|
+
// Build label lookup (id → Label) to resolve card.labelIds
|
|
48
|
+
const labelMap = buildLabelMap((board.labels ?? []));
|
|
49
|
+
// Build a lookup of columns by ID
|
|
50
|
+
const columnMap = new Map();
|
|
51
|
+
for (const col of columns) {
|
|
52
|
+
columnMap.set(col.id, col);
|
|
53
|
+
}
|
|
54
|
+
// Build column ID sets for both modes
|
|
55
|
+
const pickupColumnIds = new Set(columns
|
|
56
|
+
.filter((c) => this.pickupColumns.some((name) => name.toLowerCase() === c.name.toLowerCase()))
|
|
57
|
+
.map((c) => c.id));
|
|
58
|
+
const reviewColumnIds = new Set(columns
|
|
59
|
+
.filter((c) => this.reviewColumns.some((name) => name.toLowerCase() === c.name.toLowerCase()))
|
|
60
|
+
.map((c) => c.id));
|
|
61
|
+
// Find cards assigned to our agent in either pickup or review columns
|
|
62
|
+
const assignedCards = cards.filter((c) => c.assignee_id === this.agentUserId &&
|
|
63
|
+
!c.archived_at &&
|
|
64
|
+
(pickupColumnIds.has(c.column_id) ||
|
|
65
|
+
reviewColumnIds.has(c.column_id)));
|
|
66
|
+
const knownCardIds = this.pool.knownCardIds();
|
|
67
|
+
// All cards still assigned to the agent (any column) — used to detect
|
|
68
|
+
// genuine unassigns without false-positiving on cards the worker moved
|
|
69
|
+
// to "In Progress" or other non-pickup columns.
|
|
70
|
+
const allAgentCardIds = new Set(cards
|
|
71
|
+
.filter((c) => c.assignee_id === this.agentUserId && !c.archived_at)
|
|
72
|
+
.map((c) => c.id));
|
|
73
|
+
// Cards assigned but NOT in queue/active → enqueue (missed event)
|
|
74
|
+
for (const card of assignedCards) {
|
|
75
|
+
if (!knownCardIds.has(card.id)) {
|
|
76
|
+
const column = columnMap.get(card.column_id);
|
|
77
|
+
if (!column)
|
|
78
|
+
continue;
|
|
79
|
+
const cardLabels = resolveCardLabels(card, labelMap);
|
|
80
|
+
const subtasks = card.subtasks ?? [];
|
|
81
|
+
// Determine mode based on which column set the card is in
|
|
82
|
+
const mode = reviewColumnIds.has(card.column_id)
|
|
83
|
+
? "review"
|
|
84
|
+
: "implement";
|
|
85
|
+
// Skip already-approved cards in review mode
|
|
86
|
+
if (mode === "review" &&
|
|
87
|
+
this.approvedLabel &&
|
|
88
|
+
hasLabel(cardLabels, this.approvedLabel)) {
|
|
89
|
+
log.debug(TAG, `Skipping #${card.short_id} — already has "${this.approvedLabel}" label`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// Skip cards with "Need Review" label (awaiting human review)
|
|
93
|
+
if (mode === "review" && hasLabel(cardLabels, NEED_REVIEW_LABEL)) {
|
|
94
|
+
log.debug(TAG, `Skipping #${card.short_id} — has "${NEED_REVIEW_LABEL}" label (needs human)`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
log.info(TAG, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
|
|
98
|
+
this.pool.enqueue(card, column, cardLabels, subtasks, mode);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Cards in queue/active but no longer assigned to agent → cancel/remove
|
|
102
|
+
for (const knownId of knownCardIds) {
|
|
103
|
+
if (!allAgentCardIds.has(knownId)) {
|
|
104
|
+
log.info(TAG, `Missed unassign: ${knownId} — removing`);
|
|
105
|
+
await this.pool.removeCard(knownId);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
log.debug(TAG, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
log.error(TAG, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|