@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.
Files changed (52) hide show
  1. package/README.md +7 -6
  2. package/dist/board-helpers.d.ts +31 -0
  3. package/dist/board-helpers.js +150 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +2 -11761
  6. package/dist/completion.d.ts +14 -0
  7. package/dist/completion.js +142 -0
  8. package/dist/config.d.ts +23 -0
  9. package/dist/config.js +91 -0
  10. package/dist/git-pr.d.ts +25 -0
  11. package/dist/git-pr.js +305 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +169 -11730
  14. package/dist/log.d.ts +10 -0
  15. package/dist/log.js +35 -0
  16. package/dist/merge-monitor.d.ts +23 -0
  17. package/dist/merge-monitor.js +167 -0
  18. package/dist/pm.d.ts +14 -0
  19. package/dist/pm.js +63 -0
  20. package/dist/pool.d.ts +40 -0
  21. package/dist/pool.js +157 -0
  22. package/dist/progress-tracker.d.ts +64 -0
  23. package/dist/progress-tracker.js +361 -0
  24. package/dist/prompt.d.ts +5 -0
  25. package/dist/prompt.js +40 -0
  26. package/dist/queue.d.ts +37 -0
  27. package/dist/queue.js +96 -0
  28. package/dist/reconcile.d.ts +21 -0
  29. package/dist/reconcile.js +114 -0
  30. package/dist/review-completion.d.ts +31 -0
  31. package/dist/review-completion.js +253 -0
  32. package/dist/review-knowledge.d.ts +14 -0
  33. package/dist/review-knowledge.js +89 -0
  34. package/dist/review-prompt.d.ts +12 -0
  35. package/dist/review-prompt.js +103 -0
  36. package/dist/review-worker.d.ts +46 -0
  37. package/dist/review-worker.js +437 -0
  38. package/dist/review-worktree.d.ts +12 -0
  39. package/dist/review-worktree.js +83 -0
  40. package/dist/stream-parser.d.ts +31 -0
  41. package/dist/stream-parser.js +95 -0
  42. package/dist/types.d.ts +76 -0
  43. package/dist/types.js +56 -0
  44. package/dist/verification.d.ts +16 -0
  45. package/dist/verification.js +251 -0
  46. package/dist/watcher.d.ts +27 -0
  47. package/dist/watcher.js +74 -0
  48. package/dist/worker.d.ts +43 -0
  49. package/dist/worker.js +327 -0
  50. package/dist/worktree.d.ts +13 -0
  51. package/dist/worktree.js +115 -0
  52. 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
+ }
@@ -0,0 +1,5 @@
1
+ import type { EnrichedCard } from "./types.js";
2
+ /**
3
+ * Build the prompt that gets passed to Claude CLI for working on a card.
4
+ */
5
+ export declare function buildPrompt(enriched: EnrichedCard, branchName: string, worktreePath: string): string;
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
+ }
@@ -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
+ }