@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,189 @@
|
|
|
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 PHASES = {
|
|
7
|
+
exploring: { min: 10, max: 25, task: "Reading codebase..." },
|
|
8
|
+
implementing: { min: 25, max: 55, task: "Implementing changes..." },
|
|
9
|
+
testing: { min: 55, max: 65, task: "Running tests..." },
|
|
10
|
+
committing: { min: 65, max: 70, task: "Committing changes..." },
|
|
11
|
+
finishing: { min: 70, max: 75, task: "Finalizing..." },
|
|
12
|
+
};
|
|
13
|
+
// Tools that indicate exploring phase
|
|
14
|
+
const _EXPLORE_TOOLS = new Set(["Read", "Glob", "Grep", "Agent"]);
|
|
15
|
+
// Tools that indicate implementation
|
|
16
|
+
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
17
|
+
export class ProgressTracker {
|
|
18
|
+
client;
|
|
19
|
+
cardId;
|
|
20
|
+
workerId;
|
|
21
|
+
phase = "exploring";
|
|
22
|
+
progress = 10;
|
|
23
|
+
toolCallCount = 0;
|
|
24
|
+
hasEdited = false;
|
|
25
|
+
lastUpdateAt = 0;
|
|
26
|
+
pendingUpdate = null;
|
|
27
|
+
heartbeatTimer = null;
|
|
28
|
+
stopped = false;
|
|
29
|
+
// Subtask tracking
|
|
30
|
+
subtaskTotal;
|
|
31
|
+
subtaskCompleted;
|
|
32
|
+
subtaskMode;
|
|
33
|
+
constructor(client, cardId, workerId, subtasks) {
|
|
34
|
+
this.client = client;
|
|
35
|
+
this.cardId = cardId;
|
|
36
|
+
this.workerId = workerId;
|
|
37
|
+
this.subtaskTotal = subtasks.length;
|
|
38
|
+
this.subtaskCompleted = subtasks.filter((s) => s.completed).length;
|
|
39
|
+
this.subtaskMode = subtasks.length > 0;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Wire up the parser events and start the heartbeat.
|
|
43
|
+
*/
|
|
44
|
+
attach(parser) {
|
|
45
|
+
parser.on("tool_start", (name, input) => {
|
|
46
|
+
this.onToolStart(name, input);
|
|
47
|
+
});
|
|
48
|
+
parser.on("tool_end", (name) => {
|
|
49
|
+
this.onToolEnd(name);
|
|
50
|
+
});
|
|
51
|
+
this.startHeartbeat();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Stop all timers and flush any pending update.
|
|
55
|
+
*/
|
|
56
|
+
stop() {
|
|
57
|
+
this.stopped = true;
|
|
58
|
+
if (this.pendingUpdate) {
|
|
59
|
+
clearTimeout(this.pendingUpdate);
|
|
60
|
+
this.pendingUpdate = null;
|
|
61
|
+
}
|
|
62
|
+
if (this.heartbeatTimer) {
|
|
63
|
+
clearTimeout(this.heartbeatTimer);
|
|
64
|
+
this.heartbeatTimer = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
onToolStart(name, input) {
|
|
68
|
+
this.toolCallCount++;
|
|
69
|
+
log.debug(TAG, `Tool: ${name} (count: ${this.toolCallCount}, phase: ${this.phase})`);
|
|
70
|
+
// Detect phase transitions
|
|
71
|
+
if (!this.hasEdited && EDIT_TOOLS.has(name)) {
|
|
72
|
+
this.hasEdited = true;
|
|
73
|
+
this.transitionTo("implementing");
|
|
74
|
+
}
|
|
75
|
+
else if (this.hasEdited && name === "Bash") {
|
|
76
|
+
const cmd = this.extractBashCommand(input);
|
|
77
|
+
if (cmd && /\bgit\s+commit\b/.test(cmd)) {
|
|
78
|
+
this.transitionTo("committing");
|
|
79
|
+
}
|
|
80
|
+
else if (cmd &&
|
|
81
|
+
/\b(test|build|lint|check|tsc|vitest|jest|(?:bun|npm|pnpm|yarn) run (?:build|lint))\b/.test(cmd)) {
|
|
82
|
+
this.transitionTo("testing");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else if (name.startsWith("mcp__harmony__harmony_end_agent_session")) {
|
|
86
|
+
this.transitionTo("finishing");
|
|
87
|
+
}
|
|
88
|
+
// Handle subtask toggling — override heuristic progress
|
|
89
|
+
if (name === "mcp__harmony__harmony_toggle_subtask" && this.subtaskMode) {
|
|
90
|
+
this.subtaskCompleted = Math.min(this.subtaskCompleted + 1, this.subtaskTotal);
|
|
91
|
+
const subtaskProgress = Math.round(10 + (this.subtaskCompleted / this.subtaskTotal) * 60);
|
|
92
|
+
this.progress = Math.max(this.progress, subtaskProgress);
|
|
93
|
+
this.scheduleUpdate(`Completed subtask ${this.subtaskCompleted}/${this.subtaskTotal}`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// Increment progress within current phase bounds
|
|
97
|
+
this.incrementProgress();
|
|
98
|
+
}
|
|
99
|
+
onToolEnd(_name) {
|
|
100
|
+
// Reset heartbeat on any activity
|
|
101
|
+
this.startHeartbeat();
|
|
102
|
+
}
|
|
103
|
+
transitionTo(newPhase) {
|
|
104
|
+
if (this.phaseOrder(newPhase) <= this.phaseOrder(this.phase))
|
|
105
|
+
return;
|
|
106
|
+
log.info(TAG, `Phase: ${this.phase} → ${newPhase}`);
|
|
107
|
+
this.phase = newPhase;
|
|
108
|
+
this.progress = Math.max(this.progress, PHASES[newPhase].min);
|
|
109
|
+
this.scheduleUpdate(PHASES[newPhase].task);
|
|
110
|
+
}
|
|
111
|
+
incrementProgress() {
|
|
112
|
+
const config = PHASES[this.phase];
|
|
113
|
+
const range = config.max - config.min;
|
|
114
|
+
// Each tool call moves ~2% within the phase, so roughly 7-15 calls fill a phase
|
|
115
|
+
const step = Math.max(1, Math.round(range / 12));
|
|
116
|
+
const newProgress = Math.min(this.progress + step, config.max);
|
|
117
|
+
if (newProgress > this.progress) {
|
|
118
|
+
this.progress = newProgress;
|
|
119
|
+
this.scheduleUpdate(this.currentTaskLabel());
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
currentTaskLabel() {
|
|
123
|
+
const config = PHASES[this.phase];
|
|
124
|
+
return config.task;
|
|
125
|
+
}
|
|
126
|
+
scheduleUpdate(currentTask) {
|
|
127
|
+
if (this.stopped)
|
|
128
|
+
return;
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
const elapsed = now - this.lastUpdateAt;
|
|
131
|
+
if (elapsed >= THROTTLE_MS) {
|
|
132
|
+
// Can send immediately
|
|
133
|
+
this.sendUpdate(currentTask);
|
|
134
|
+
}
|
|
135
|
+
else if (!this.pendingUpdate) {
|
|
136
|
+
// Schedule for after throttle window
|
|
137
|
+
const delay = THROTTLE_MS - elapsed;
|
|
138
|
+
this.pendingUpdate = setTimeout(() => {
|
|
139
|
+
this.pendingUpdate = null;
|
|
140
|
+
if (!this.stopped) {
|
|
141
|
+
this.sendUpdate(currentTask);
|
|
142
|
+
}
|
|
143
|
+
}, delay);
|
|
144
|
+
}
|
|
145
|
+
// If there's already a pending update, it will fire soon — skip
|
|
146
|
+
}
|
|
147
|
+
sendUpdate(currentTask) {
|
|
148
|
+
this.lastUpdateAt = Date.now();
|
|
149
|
+
log.debug(TAG, `Progress: ${this.progress}% — ${currentTask}`);
|
|
150
|
+
this.client
|
|
151
|
+
.updateAgentProgress(this.cardId, {
|
|
152
|
+
agentIdentifier: agentIdentifier(this.workerId),
|
|
153
|
+
agentName: AGENT_NAME,
|
|
154
|
+
status: "working",
|
|
155
|
+
currentTask,
|
|
156
|
+
progressPercent: this.progress,
|
|
157
|
+
})
|
|
158
|
+
.catch((err) => {
|
|
159
|
+
log.warn(TAG, `Failed to send progress update: ${err}`);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
startHeartbeat() {
|
|
163
|
+
if (this.heartbeatTimer) {
|
|
164
|
+
clearTimeout(this.heartbeatTimer);
|
|
165
|
+
}
|
|
166
|
+
this.heartbeatTimer = setTimeout(() => {
|
|
167
|
+
if (!this.stopped) {
|
|
168
|
+
this.sendUpdate("Still working...");
|
|
169
|
+
this.startHeartbeat();
|
|
170
|
+
}
|
|
171
|
+
}, HEARTBEAT_MS);
|
|
172
|
+
}
|
|
173
|
+
phaseOrder(phase) {
|
|
174
|
+
const order = [
|
|
175
|
+
"exploring",
|
|
176
|
+
"implementing",
|
|
177
|
+
"testing",
|
|
178
|
+
"committing",
|
|
179
|
+
"finishing",
|
|
180
|
+
];
|
|
181
|
+
return order.indexOf(phase);
|
|
182
|
+
}
|
|
183
|
+
extractBashCommand(input) {
|
|
184
|
+
if (typeof input === "object" && input !== null && "command" in input) {
|
|
185
|
+
return String(input.command);
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
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,107 @@
|
|
|
1
|
+
import { hasApprovedLabel } from "./board-helpers.js";
|
|
2
|
+
import { log } from "./log.js";
|
|
3
|
+
const TAG = "reconcile";
|
|
4
|
+
/**
|
|
5
|
+
* Reconciliation heartbeat: polls the board every `intervalMs` to catch
|
|
6
|
+
* missed realtime events and sync state.
|
|
7
|
+
*/
|
|
8
|
+
export class Reconciler {
|
|
9
|
+
client;
|
|
10
|
+
pool;
|
|
11
|
+
projectId;
|
|
12
|
+
agentUserId;
|
|
13
|
+
pickupColumns;
|
|
14
|
+
reviewColumns;
|
|
15
|
+
approvedLabel;
|
|
16
|
+
intervalMs;
|
|
17
|
+
timer = null;
|
|
18
|
+
constructor(client, pool, projectId, agentUserId, pickupColumns, reviewColumns, approvedLabel, intervalMs = 60_000) {
|
|
19
|
+
this.client = client;
|
|
20
|
+
this.pool = pool;
|
|
21
|
+
this.projectId = projectId;
|
|
22
|
+
this.agentUserId = agentUserId;
|
|
23
|
+
this.pickupColumns = pickupColumns;
|
|
24
|
+
this.reviewColumns = reviewColumns;
|
|
25
|
+
this.approvedLabel = approvedLabel;
|
|
26
|
+
this.intervalMs = intervalMs;
|
|
27
|
+
}
|
|
28
|
+
start() {
|
|
29
|
+
log.info(TAG, `Heartbeat every ${this.intervalMs / 1000}s`);
|
|
30
|
+
// Run immediately, then on interval
|
|
31
|
+
this.tick();
|
|
32
|
+
this.timer = setInterval(() => this.tick(), this.intervalMs);
|
|
33
|
+
}
|
|
34
|
+
stop() {
|
|
35
|
+
if (this.timer) {
|
|
36
|
+
clearInterval(this.timer);
|
|
37
|
+
this.timer = null;
|
|
38
|
+
}
|
|
39
|
+
log.info(TAG, "Heartbeat stopped");
|
|
40
|
+
}
|
|
41
|
+
async tick() {
|
|
42
|
+
try {
|
|
43
|
+
const board = await this.client.getBoard(this.projectId);
|
|
44
|
+
const cards = (board.cards ?? []);
|
|
45
|
+
const columns = board.columns;
|
|
46
|
+
const _labels = (board.labels ?? []);
|
|
47
|
+
// Build a lookup of columns by ID
|
|
48
|
+
const columnMap = new Map();
|
|
49
|
+
for (const col of columns) {
|
|
50
|
+
columnMap.set(col.id, col);
|
|
51
|
+
}
|
|
52
|
+
// Build column ID sets for both modes
|
|
53
|
+
const pickupColumnIds = new Set(columns
|
|
54
|
+
.filter((c) => this.pickupColumns.some((name) => name.toLowerCase() === c.name.toLowerCase()))
|
|
55
|
+
.map((c) => c.id));
|
|
56
|
+
const reviewColumnIds = new Set(columns
|
|
57
|
+
.filter((c) => this.reviewColumns.some((name) => name.toLowerCase() === c.name.toLowerCase()))
|
|
58
|
+
.map((c) => c.id));
|
|
59
|
+
// Find cards assigned to our agent in either pickup or review columns
|
|
60
|
+
const assignedCards = cards.filter((c) => c.assignee_id === this.agentUserId &&
|
|
61
|
+
!c.archived_at &&
|
|
62
|
+
(pickupColumnIds.has(c.column_id) ||
|
|
63
|
+
reviewColumnIds.has(c.column_id)));
|
|
64
|
+
const knownCardIds = this.pool.knownCardIds();
|
|
65
|
+
// All cards still assigned to the agent (any column) — used to detect
|
|
66
|
+
// genuine unassigns without false-positiving on cards the worker moved
|
|
67
|
+
// to "In Progress" or other non-pickup columns.
|
|
68
|
+
const allAgentCardIds = new Set(cards
|
|
69
|
+
.filter((c) => c.assignee_id === this.agentUserId && !c.archived_at)
|
|
70
|
+
.map((c) => c.id));
|
|
71
|
+
// Cards assigned but NOT in queue/active → enqueue (missed event)
|
|
72
|
+
for (const card of assignedCards) {
|
|
73
|
+
if (!knownCardIds.has(card.id)) {
|
|
74
|
+
const column = columnMap.get(card.column_id);
|
|
75
|
+
if (!column)
|
|
76
|
+
continue;
|
|
77
|
+
const cardLabels = card.labels ?? [];
|
|
78
|
+
const subtasks = card.subtasks ?? [];
|
|
79
|
+
// Determine mode based on which column set the card is in
|
|
80
|
+
const mode = reviewColumnIds.has(card.column_id)
|
|
81
|
+
? "review"
|
|
82
|
+
: "implement";
|
|
83
|
+
// Skip already-approved cards in review mode
|
|
84
|
+
if (mode === "review" &&
|
|
85
|
+
this.approvedLabel &&
|
|
86
|
+
hasApprovedLabel(cardLabels, this.approvedLabel)) {
|
|
87
|
+
log.debug(TAG, `Skipping #${card.short_id} — already has "${this.approvedLabel}" label`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
log.info(TAG, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
|
|
91
|
+
this.pool.enqueue(card, column, cardLabels, subtasks, mode);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Cards in queue/active but no longer assigned to agent → cancel/remove
|
|
95
|
+
for (const knownId of knownCardIds) {
|
|
96
|
+
if (!allAgentCardIds.has(knownId)) {
|
|
97
|
+
log.info(TAG, `Missed unassign: ${knownId} — removing`);
|
|
98
|
+
await this.pool.removeCard(knownId);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
log.debug(TAG, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
log.error(TAG, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
2
|
+
import type { Card } from "@harmony/shared";
|
|
3
|
+
import type { AgentConfig } from "./types.js";
|
|
4
|
+
export interface ReviewFinding {
|
|
5
|
+
severity: "critical" | "major" | "minor";
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
category?: string;
|
|
9
|
+
location?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ScopeCheck {
|
|
12
|
+
status: "clean" | "drift" | "missing";
|
|
13
|
+
notes?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ReviewResult {
|
|
16
|
+
verdict: "approved" | "rejected";
|
|
17
|
+
summary: string;
|
|
18
|
+
scopeCheck?: ScopeCheck;
|
|
19
|
+
findings: ReviewFinding[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Parse Claude's review output into a structured ReviewResult.
|
|
23
|
+
* Looks for a JSON block in the output.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseReviewOutput(stdout: string): ReviewResult;
|
|
26
|
+
/**
|
|
27
|
+
* Post-review completion pipeline.
|
|
28
|
+
* Handles approved/rejected verdicts, creates subtasks for findings,
|
|
29
|
+
* and moves the card to the appropriate column.
|
|
30
|
+
*/
|
|
31
|
+
export declare function runReviewCompletion(client: HarmonyApiClient, card: Card, result: ReviewResult, config: AgentConfig, worktreePath: string, branchName: string): Promise<void>;
|