@gethmy/agent 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/budget.d.ts CHANGED
@@ -38,8 +38,10 @@ export declare class BudgetGuard {
38
38
  */
39
39
  isTerminal(reason: GuardReason): boolean;
40
40
  /**
41
- * Apply the DLQ label to a card and post a comment explaining why.
42
- * Safe to call repeatedly labels are idempotent.
41
+ * Apply the DLQ label to a card, persist the reason, and append a
42
+ * post-mortem block to the card description listing the last 3 failure
43
+ * summaries. Safe to call repeatedly — labels are idempotent and the
44
+ * description block is delimited so reruns replace rather than stack.
43
45
  */
44
46
  markDlq(client: HarmonyApiClient, card: Card, reason: GuardReason, detail: string): Promise<void>;
45
47
  }
package/dist/budget.js CHANGED
@@ -71,8 +71,10 @@ export class BudgetGuard {
71
71
  reason === "card_cost_cap");
72
72
  }
73
73
  /**
74
- * Apply the DLQ label to a card and post a comment explaining why.
75
- * Safe to call repeatedly labels are idempotent.
74
+ * Apply the DLQ label to a card, persist the reason, and append a
75
+ * post-mortem block to the card description listing the last 3 failure
76
+ * summaries. Safe to call repeatedly — labels are idempotent and the
77
+ * description block is delimited so reruns replace rather than stack.
76
78
  */
77
79
  async markDlq(client, card, reason, detail) {
78
80
  await this.store.markDlq(card.id, `${reason}: ${detail}`);
@@ -86,9 +88,46 @@ export class BudgetGuard {
86
88
  catch (err) {
87
89
  log.warn(TAG, `failed to add dlq label to #${card.short_id}: ${err instanceof Error ? err.message : err}`);
88
90
  }
91
+ try {
92
+ const recent = this.store.getRecentFailures(card.id, 3);
93
+ const block = buildDlqDescriptionBlock(reason, detail, recent);
94
+ const existing = card.description ?? "";
95
+ const stripped = stripDlqBlock(existing);
96
+ await client.updateCard(card.id, {
97
+ description: `${stripped}${stripped ? "\n\n" : ""}${block}`,
98
+ });
99
+ }
100
+ catch (err) {
101
+ log.warn(TAG, `failed to post DLQ summary to #${card.short_id}: ${err instanceof Error ? err.message : err}`);
102
+ }
89
103
  log.warn(TAG, `#${card.short_id} DLQ'd — ${reason}: ${detail}`);
90
104
  }
91
105
  }
106
+ const DLQ_MARKER = "---\n**Agent DLQ**";
107
+ function buildDlqDescriptionBlock(reason, detail, failures) {
108
+ const lines = [DLQ_MARKER, `Cap hit: ${reason} — ${detail}`];
109
+ if (failures.length > 0) {
110
+ lines.push("", "Recent failures:");
111
+ for (const f of failures) {
112
+ const when = new Date(f.ts).toISOString().replace("T", " ").slice(0, 16);
113
+ const tag = f.reason ? ` [${f.reason}]` : "";
114
+ const branch = f.recoveryBranch
115
+ ? `\n recover: \`git fetch && git checkout ${f.recoveryBranch}\``
116
+ : "";
117
+ lines.push(`- ${when} UTC${tag} — ${f.summary}${branch}`);
118
+ }
119
+ }
120
+ else {
121
+ lines.push("", "_No prior failure summaries recorded._");
122
+ }
123
+ return lines.join("\n");
124
+ }
125
+ function stripDlqBlock(description) {
126
+ const idx = description.indexOf(DLQ_MARKER);
127
+ if (idx < 0)
128
+ return description.trimEnd();
129
+ return description.slice(0, idx).trimEnd();
130
+ }
92
131
  function formatCents(cents) {
93
132
  return `$${(cents / 100).toFixed(2)}`;
94
133
  }
@@ -1,5 +1,6 @@
1
1
  import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
2
2
  import type { Card } from "@harmony/shared";
3
+ import type { StateStore } from "./state-store.js";
3
4
  import type { CostUpdate } from "./stream-parser.js";
4
5
  import { type AgentConfig } from "./types.js";
5
6
  export interface SessionStats {
@@ -28,4 +29,4 @@ export declare function buildTokenPayload(stats?: SessionStats | null): {
28
29
  /**
29
30
  * Post-work pipeline: push branch, create PR, move card, post summary.
30
31
  */
31
- export declare function runCompletion(client: HarmonyApiClient, card: Card, branchName: string, worktreePath: string, config: AgentConfig, workerId?: number, sessionStats?: SessionStats, workspaceId?: string, agentSessionId?: string | null): Promise<void>;
32
+ export declare function runCompletion(client: HarmonyApiClient, card: Card, branchName: string, worktreePath: string, config: AgentConfig, workerId?: number, sessionStats?: SessionStats, workspaceId?: string, agentSessionId?: string | null, stateStore?: StateStore): Promise<void>;
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { moveCardToColumn } from "./board-helpers.js";
3
3
  import { writeEpisode } from "./episode-writer.js";
4
- import { createPullRequest, detectGitProvider, pushBranch } from "./git-pr.js";
4
+ import { createPullRequest, detectGitProvider, getBranchWebUrl, pushBranch, } from "./git-pr.js";
5
5
  import { log } from "./log.js";
6
6
  import { AGENT_NAME, agentIdentifier } from "./types.js";
7
7
  import { attemptAutoFix, reportFindings, runVerification, } from "./verification.js";
@@ -29,7 +29,7 @@ export function buildTokenPayload(stats) {
29
29
  /**
30
30
  * Post-work pipeline: push branch, create PR, move card, post summary.
31
31
  */
32
- export async function runCompletion(client, card, branchName, worktreePath, config, workerId = 0, sessionStats, workspaceId, agentSessionId) {
32
+ export async function runCompletion(client, card, branchName, worktreePath, config, workerId = 0, sessionStats, workspaceId, agentSessionId, stateStore) {
33
33
  // Hoisted so the episode write hook can read final verification state.
34
34
  let verificationResult = {
35
35
  passed: true,
@@ -49,7 +49,22 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
49
49
  cleanupWorktree(worktreePath, branchName);
50
50
  return;
51
51
  }
52
- // 1. Verification gate
52
+ // 1. Push branch FIRST so commits are durable on origin regardless of
53
+ // verification outcome. A failed verify (below) then preserves the work
54
+ // under `agent-attempts/*` for `failedAttemptRetentionDays`. Without this
55
+ // ordering, verify failures used to orphan commits in a deleted worktree —
56
+ // recoverable only via `git reflog`.
57
+ log.info(TAG, `Pushing branch ${branchName} (pre-verify)...`);
58
+ try {
59
+ pushBranch(branchName, worktreePath);
60
+ }
61
+ catch (err) {
62
+ // Push failure shouldn't prevent verification from running, but the
63
+ // safety guarantee is gone — surface it loudly so the operator notices.
64
+ log.error(TAG, `pre-verify push failed for ${branchName}: ${err instanceof Error ? err.message : err}`);
65
+ }
66
+ const recoveryUrl = getBranchWebUrl(branchName, worktreePath);
67
+ // 2. Verification gate
53
68
  if (config.verification.enabled) {
54
69
  await client.updateAgentProgress(card.id, {
55
70
  agentIdentifier: agentIdentifier(workerId),
@@ -59,6 +74,7 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
59
74
  progressPercent: 80,
60
75
  });
61
76
  let result = await runVerification(worktreePath, config, workerId);
77
+ let autoFixAttempts = 0;
62
78
  if (!result.passed && config.verification.autoFix) {
63
79
  for (let attempt = 0; attempt < config.verification.maxFixAttempts; attempt++) {
64
80
  log.info(TAG, `Auto-fix attempt ${attempt + 1}/${config.verification.maxFixAttempts}`);
@@ -72,8 +88,16 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
72
88
  const allErrors = [...result.buildErrors, ...result.lintWarnings];
73
89
  await attemptAutoFix(worktreePath, config, allErrors);
74
90
  result = await runVerification(worktreePath, config, workerId);
91
+ autoFixAttempts = attempt + 1;
75
92
  if (result.passed) {
76
93
  log.info(TAG, `Auto-fix succeeded on attempt ${attempt + 1}`);
94
+ // Push again so the auto-fix commit is also durable on origin.
95
+ try {
96
+ pushBranch(branchName, worktreePath);
97
+ }
98
+ catch (err) {
99
+ log.warn(TAG, `post-fix push failed for ${branchName}: ${err instanceof Error ? err.message : err}`);
100
+ }
77
101
  break;
78
102
  }
79
103
  }
@@ -81,23 +105,44 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
81
105
  verificationResult = result;
82
106
  if (!result.passed) {
83
107
  log.warn(TAG, `Verification failed for #${card.short_id} — reporting findings`);
84
- await reportFindings(client, card.id, result);
108
+ // Push the latest tip (including any auto-fix attempts) so the
109
+ // failed branch on origin reflects what verify saw.
110
+ try {
111
+ pushBranch(branchName, worktreePath);
112
+ }
113
+ catch (err) {
114
+ log.warn(TAG, `post-fail push failed for ${branchName}: ${err instanceof Error ? err.message : err}`);
115
+ }
116
+ const failureSummary = buildVerificationFailureSummary(result, autoFixAttempts);
117
+ try {
118
+ await stateStore?.recordFailureSummary(card.id, {
119
+ summary: failureSummary,
120
+ reason: "verification",
121
+ recoveryBranch: branchName,
122
+ });
123
+ }
124
+ catch (err) {
125
+ log.debug(TAG, `recordFailureSummary failed: ${err instanceof Error ? err.message : err}`);
126
+ }
127
+ await reportFindings(client, card.id, result, {
128
+ branchName,
129
+ branchUrl: recoveryUrl,
130
+ });
85
131
  await moveCardToColumn(client, card, config.verification.failColumn);
86
132
  await client.endAgentSession(card.id, {
87
- status: "paused",
133
+ status: "failed",
134
+ failureReason: "verification",
135
+ failureSummary,
136
+ recoveryBranch: branchName,
88
137
  ...buildTokenPayload(sessionStats),
89
138
  });
90
- // Episode write: paused/orphaned runs skip silently (plan D8). Failure
91
- // here would only fire on a status===completed path, which we don't
92
- // hit when verification fails.
139
+ // Local-only cleanup. The remote ref under `agent-attempts/*` stays
140
+ // up; the GC sweep (worktree-gc.ts) prunes it after retention.
93
141
  cleanupWorktree(worktreePath, branchName);
94
142
  return;
95
143
  }
96
144
  log.info(TAG, `Verification passed for #${card.short_id}`);
97
145
  }
98
- // 2. Push branch (force-push on rework if remote branch already exists)
99
- log.info(TAG, `Pushing branch ${branchName}...`);
100
- pushBranch(branchName, worktreePath);
101
146
  // 3. Create PR
102
147
  let prUrl = null;
103
148
  if (config.completion.createPR) {
@@ -144,6 +189,21 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
144
189
  cleanupWorktree(worktreePath, branchName);
145
190
  log.info(TAG, `Completion done for #${card.short_id}${prUrl ? ` — PR: ${prUrl}` : ""}`);
146
191
  }
192
+ function buildVerificationFailureSummary(result, autoFixAttempts) {
193
+ const counts = [];
194
+ if (result.buildErrors.length > 0) {
195
+ counts.push(`${result.buildErrors.length} build error(s)`);
196
+ }
197
+ if (result.lintWarnings.length > 0) {
198
+ counts.push(`${result.lintWarnings.length} lint issue(s)`);
199
+ }
200
+ if (result.reviewFindings.length > 0) {
201
+ counts.push(`${result.reviewFindings.length} review finding(s)`);
202
+ }
203
+ const head = counts.length > 0 ? counts.join(", ") : "verification failed";
204
+ const tail = autoFixAttempts > 0 ? ` after ${autoFixAttempts} auto-fix attempt(s)` : "";
205
+ return `${head}${tail}`;
206
+ }
147
207
  function checkHasCommits(worktreePath, baseBranch) {
148
208
  try {
149
209
  const count = execFileSync("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`], { cwd: worktreePath, encoding: "utf-8" }).trim();
package/dist/git-pr.d.ts CHANGED
@@ -19,6 +19,19 @@ export declare function checkPrMergeStatus(prUrl: string, cwd: string, provider:
19
19
  export declare function extractPrUrl(description: string | null): string | null;
20
20
  export declare function remoteBranchExists(branchName: string, cwd: string): boolean;
21
21
  export declare function pushBranch(branchName: string, cwd: string): void;
22
+ /**
23
+ * Push the current branch's tip to `newRef` on origin and delete `oldRef`.
24
+ * Used when an approved attempt graduates from `agent-attempts/*` to
25
+ * `agent/*` — keeps the commits durable across the rename and avoids any
26
+ * window where the work is unreachable on origin.
27
+ */
28
+ export declare function renameRemoteBranch(oldRef: string, newRef: string, cwd: string): void;
29
+ /**
30
+ * Best-effort public branch URL for the recovery button on a failed session.
31
+ * Returns null when we can't infer a tree URL — the daemon falls back to a
32
+ * plain `git fetch && git checkout <ref>` instruction in that case.
33
+ */
34
+ export declare function getBranchWebUrl(branchName: string, cwd: string): string | null;
22
35
  export declare function buildPrBody(card: Card, commitLog: string): string;
23
36
  export declare function createPullRequest(card: Card, branchName: string, worktreePath: string, config: AgentConfig, provider: GitProvider): string | null;
24
37
  export declare function findExistingPr(branchName: string, worktreePath: string, provider: GitProvider): string | null;
package/dist/git-pr.js CHANGED
@@ -167,6 +167,79 @@ export function pushBranch(branchName, cwd) {
167
167
  });
168
168
  }
169
169
  }
170
+ /**
171
+ * Push the current branch's tip to `newRef` on origin and delete `oldRef`.
172
+ * Used when an approved attempt graduates from `agent-attempts/*` to
173
+ * `agent/*` — keeps the commits durable across the rename and avoids any
174
+ * window where the work is unreachable on origin.
175
+ */
176
+ export function renameRemoteBranch(oldRef, newRef, cwd) {
177
+ if (oldRef === newRef)
178
+ return;
179
+ let sha;
180
+ try {
181
+ sha = execFileSync("git", ["rev-parse", "HEAD"], {
182
+ cwd,
183
+ encoding: "utf-8",
184
+ }).trim();
185
+ }
186
+ catch (err) {
187
+ throw new Error(`renameRemoteBranch: could not resolve HEAD: ${err instanceof Error ? err.message : err}`);
188
+ }
189
+ log.info(TAG, `Renaming remote ${oldRef} → ${newRef}`);
190
+ execFileSync("git", ["push", "origin", `${sha}:refs/heads/${newRef}`, "--force-with-lease"], { cwd, stdio: "pipe" });
191
+ try {
192
+ execFileSync("git", ["push", "origin", `:refs/heads/${oldRef}`], {
193
+ cwd,
194
+ stdio: "pipe",
195
+ });
196
+ }
197
+ catch (err) {
198
+ log.warn(TAG, `renameRemoteBranch: could not delete old ref ${oldRef}: ${err instanceof Error ? err.message : err}`);
199
+ }
200
+ try {
201
+ execFileSync("git", ["branch", "-m", oldRef, newRef], {
202
+ cwd,
203
+ stdio: "pipe",
204
+ });
205
+ }
206
+ catch {
207
+ // Worktree may not have the old branch checked out — non-fatal.
208
+ }
209
+ }
210
+ /**
211
+ * Best-effort public branch URL for the recovery button on a failed session.
212
+ * Returns null when we can't infer a tree URL — the daemon falls back to a
213
+ * plain `git fetch && git checkout <ref>` instruction in that case.
214
+ */
215
+ export function getBranchWebUrl(branchName, cwd) {
216
+ try {
217
+ const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
218
+ cwd,
219
+ encoding: "utf-8",
220
+ }).trim();
221
+ const encoded = branchName.split("/").map(encodeURIComponent).join("/");
222
+ if (/github\.com[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
223
+ const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
224
+ if (m)
225
+ return `https://github.com/${m[1]}/${m[2]}/tree/${encoded}`;
226
+ }
227
+ if (/gitlab\.com[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
228
+ const m = remoteUrl.match(/gitlab\.com[:/](.+?)(?:\.git)?$/);
229
+ if (m)
230
+ return `https://gitlab.com/${m[1]}/-/tree/${encoded}`;
231
+ }
232
+ if (/bitbucket\.org[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
233
+ const m = remoteUrl.match(/bitbucket\.org[:/](.+?)(?:\.git)?$/);
234
+ if (m)
235
+ return `https://bitbucket.org/${m[1]}/branch/${encoded}`;
236
+ }
237
+ return null;
238
+ }
239
+ catch {
240
+ return null;
241
+ }
242
+ }
170
243
  // ============ PR CREATION (PROVIDER-AWARE) ============
171
244
  export function buildPrBody(card, commitLog) {
172
245
  return [
package/dist/index.js CHANGED
@@ -146,7 +146,12 @@ export async function main() {
146
146
  }
147
147
  // Periodic worktree GC — removes orphan worktrees older than 1h that
148
148
  // no active run claims. Covers crashes that left worktrees behind.
149
- const worktreeGc = new WorktreeGc(config.agent.worktree.basePath, stateStore, config.agent.timing.worktreeGcIntervalMs);
149
+ const worktreeGc = new WorktreeGc(config.agent.worktree.basePath, stateStore, config.agent.timing.worktreeGcIntervalMs, config.agent.worktree.failedAttemptRetentionDays > 0
150
+ ? {
151
+ prefix: config.agent.worktree.failedBranchPrefix,
152
+ retentionDays: config.agent.worktree.failedAttemptRetentionDays,
153
+ }
154
+ : undefined);
150
155
  // Local status/control HTTP server.
151
156
  const startedAt = Date.now();
152
157
  const httpServer = config.agent.http.enabled
package/dist/pool.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
2
2
  import type { Card, Column, Label, Subtask } from "@harmony/shared";
3
3
  import { PriorityQueue } from "./queue.js";
4
4
  import type { StateStore } from "./state-store.js";
5
- import type { AgentConfig, WorkMode } from "./types.js";
5
+ import { type AgentConfig, type WorkMode } from "./types.js";
6
6
  export declare class Pool {
7
7
  private client;
8
8
  private implWorkers;
@@ -19,6 +19,11 @@ export declare class Pool {
19
19
  * and manual API calls all go through the same gate.
20
20
  */
21
21
  enqueue(card: Card, column: Column, labels: Label[], subtasks: Subtask[], mode?: WorkMode): Promise<void>;
22
+ /**
23
+ * Best-effort waiting-state emit. Failures are swallowed because we don't
24
+ * want a board-API hiccup to drop the queue/budget event in pool.ts.
25
+ */
26
+ private emitWaiting;
22
27
  /**
23
28
  * Remove a card from any queue or cancel an active worker.
24
29
  */
package/dist/pool.js CHANGED
@@ -2,6 +2,7 @@ import { BudgetGuard } from "./budget.js";
2
2
  import { log } from "./log.js";
3
3
  import { PriorityQueue } from "./queue.js";
4
4
  import { ReviewWorker } from "./review-worker.js";
5
+ import { AGENT_NAME, agentIdentifier, } from "./types.js";
5
6
  import { Worker } from "./worker.js";
6
7
  const TAG = "pool";
7
8
  export class Pool {
@@ -58,6 +59,11 @@ export class Pool {
58
59
  if (this.budget.isTerminal(decision.reason)) {
59
60
  await this.budget.markDlq(this.client, card, decision.reason, decision.detail);
60
61
  }
62
+ else if (decision.reason === "daily_budget") {
63
+ // Soft pause — surface as `waiting` so the board signals that
64
+ // the daemon will pick this card up after the budget resets.
65
+ await this.emitWaiting(card.id, `Daily budget reached — waiting for reset (${decision.detail})`);
66
+ }
61
67
  }
62
68
  else {
63
69
  log.debug(TAG, `#${card.short_id} in DLQ: ${decision.detail}`);
@@ -70,7 +76,34 @@ export class Pool {
70
76
  // Store card data for when it gets dispatched
71
77
  this.cardDataCache.set(card.id, { card, column, labels, subtasks, mode });
72
78
  const workers = mode === "review" ? this.reviewWorkers : this.implWorkers;
73
- this.tryDispatchFor(workers, queue, mode);
79
+ const dispatched = this.tryDispatchFor(workers, queue, mode);
80
+ if (!dispatched) {
81
+ // No idle worker — the card will wait in the queue. Surface the
82
+ // queue position so the board ribbon shows the user something is
83
+ // happening even though no worker has the card yet.
84
+ const position = queue.cardIds().indexOf(card.id) + 1;
85
+ const total = queue.length;
86
+ await this.emitWaiting(card.id, position > 0
87
+ ? `Queued (${position}/${total}) — waiting for ${mode} worker`
88
+ : `Queued — waiting for ${mode} worker`);
89
+ }
90
+ }
91
+ /**
92
+ * Best-effort waiting-state emit. Failures are swallowed because we don't
93
+ * want a board-API hiccup to drop the queue/budget event in pool.ts.
94
+ */
95
+ async emitWaiting(cardId, currentTask) {
96
+ try {
97
+ await this.client.updateAgentProgress(cardId, {
98
+ agentIdentifier: agentIdentifier(0),
99
+ agentName: AGENT_NAME,
100
+ status: "waiting",
101
+ currentTask,
102
+ });
103
+ }
104
+ catch (err) {
105
+ log.debug(TAG, `waiting emit failed for ${cardId}: ${err instanceof Error ? err.message : err}`);
106
+ }
74
107
  }
75
108
  /**
76
109
  * Remove a card from any queue or cancel an active worker.
@@ -203,18 +236,19 @@ export class Pool {
203
236
  const idle = workers.find((w) => w.isIdle);
204
237
  if (!idle) {
205
238
  log.debug(TAG, `No idle ${label} workers (queue: ${queue.length})`);
206
- return;
239
+ return false;
207
240
  }
208
241
  const next = queue.dequeue();
209
242
  if (!next)
210
- return;
243
+ return false;
211
244
  const data = this.cardDataCache.get(next.cardId);
212
245
  if (!data) {
213
246
  log.warn(TAG, `No cached data for card ${next.cardId}, skipping`);
214
- return;
247
+ return false;
215
248
  }
216
249
  this.cardDataCache.delete(next.cardId);
217
250
  log.info(TAG, `Dispatching #${next.shortId} to ${label} worker ${idle.id}`);
218
251
  idle.run(data.card, data.column, data.labels, data.subtasks);
252
+ return true;
219
253
  }
220
254
  }
package/dist/recovery.js CHANGED
@@ -70,12 +70,18 @@ export async function recoverOrphans(store, client, config) {
70
70
  }
71
71
  export async function recoverRun(run, store, client, config, outcome) {
72
72
  // 1. End the agent session so the card stops showing the progress ring.
73
+ // Mark as failed (not paused) — daemon crash is an outcome, not a
74
+ // user-initiated pause. UI renders the destructive tint + recovery branch
75
+ // button if the run had pushed any commits.
73
76
  try {
74
77
  await client.endAgentSession(run.cardId, {
75
- status: "paused",
78
+ status: "failed",
79
+ failureReason: "daemon_restart",
80
+ failureSummary: `Daemon restarted mid-${run.pipeline} (phase: ${run.phase})`,
81
+ recoveryBranch: run.branchName ?? undefined,
76
82
  progressPercent: run.phase === "completing" ? 95 : undefined,
77
83
  });
78
- outcome.actions.push("ended agent session (paused)");
84
+ outcome.actions.push("ended agent session (failed: daemon_restart)");
79
85
  }
80
86
  catch (err) {
81
87
  const msg = err instanceof Error ? err.message : String(err);
@@ -1,6 +1,7 @@
1
1
  import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
2
2
  import type { Card } from "@harmony/shared";
3
3
  import { type SessionStats } from "./completion.js";
4
+ import type { StateStore } from "./state-store.js";
4
5
  import { type AgentConfig } from "./types.js";
5
6
  export interface ReviewFinding {
6
7
  severity: "critical" | "major" | "minor";
@@ -36,4 +37,4 @@ export declare function parseReviewOutput(stdout: string): ReviewResult;
36
37
  * Handles approved/rejected verdicts, creates subtasks for findings,
37
38
  * and moves the card to the appropriate column.
38
39
  */
39
- export declare function runReviewCompletion(client: HarmonyApiClient, card: Card, result: ReviewResult, config: AgentConfig, worktreePath: string, branchName: string | null, sessionStats?: SessionStats | null, runLogPath?: string | null, workspaceId?: string, agentSessionId?: string | null): Promise<void>;
40
+ export declare function runReviewCompletion(client: HarmonyApiClient, card: Card, result: ReviewResult, config: AgentConfig, worktreePath: string, branchName: string | null, sessionStats?: SessionStats | null, runLogPath?: string | null, workspaceId?: string, agentSessionId?: string | null, stateStore?: StateStore): Promise<void>;
@@ -2,7 +2,7 @@ import { readFileSync, statSync } from "node:fs";
2
2
  import { addLabelByName, moveCardToColumn } from "./board-helpers.js";
3
3
  import { buildTokenPayload } from "./completion.js";
4
4
  import { backfillReviewVerdict, findLatestImplementEpisode, writeEpisode, } from "./episode-writer.js";
5
- import { createPullRequest, detectGitProvider, pushBranch } from "./git-pr.js";
5
+ import { createPullRequest, detectGitProvider, getBranchWebUrl, pushBranch, renameRemoteBranch, } from "./git-pr.js";
6
6
  import { log } from "./log.js";
7
7
  import { NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR, } from "./types.js";
8
8
  import { cleanupWorktree } from "./worktree.js";
@@ -183,7 +183,7 @@ function stripReviewSummary(description) {
183
183
  * Handles approved/rejected verdicts, creates subtasks for findings,
184
184
  * and moves the card to the appropriate column.
185
185
  */
186
- export async function runReviewCompletion(client, card, result, config, worktreePath, branchName, sessionStats, runLogPath, workspaceId, agentSessionId) {
186
+ export async function runReviewCompletion(client, card, result, config, worktreePath, branchName, sessionStats, runLogPath, workspaceId, agentSessionId, stateStore) {
187
187
  // Re-fetch card for fresh description (avoids stale data from enqueue time)
188
188
  let freshDesc;
189
189
  try {
@@ -248,12 +248,30 @@ export async function runReviewCompletion(client, card, result, config, worktree
248
248
  if (result.verdict === "approved") {
249
249
  // Ensure branch is pushed (skip in local mode — no branch to push)
250
250
  let prUrl = null;
251
+ let approvedBranch = branchName;
251
252
  if (branchName) {
252
253
  pushBranch(branchName, worktreePath);
254
+ // Graduate the branch from `agent-attempts/*` to `agent/*` so the
255
+ // approved PR opens on a clean ref. Renaming on origin is force-with-
256
+ // lease + delete-old; the old ref is the same SHA, so no work is lost.
257
+ const failedPrefix = config.worktree.failedBranchPrefix;
258
+ const approvedPrefix = config.worktree.approvedBranchPrefix;
259
+ if (failedPrefix &&
260
+ approvedPrefix &&
261
+ branchName.startsWith(failedPrefix)) {
262
+ const newRef = `${approvedPrefix}${branchName.slice(failedPrefix.length)}`;
263
+ try {
264
+ renameRemoteBranch(branchName, newRef, worktreePath);
265
+ approvedBranch = newRef;
266
+ }
267
+ catch (err) {
268
+ log.warn(TAG, `Branch rename failed (continuing on ${branchName}): ${err instanceof Error ? err.message : err}`);
269
+ }
270
+ }
253
271
  // Create PR if configured
254
- if (config.review.createPR) {
272
+ if (config.review.createPR && approvedBranch) {
255
273
  const provider = detectGitProvider(worktreePath);
256
- prUrl = createPullRequest(card, branchName, worktreePath, config, provider);
274
+ prUrl = createPullRequest(card, approvedBranch, worktreePath, config, provider);
257
275
  }
258
276
  }
259
277
  // Add "Ready to Merge" label
@@ -401,8 +419,29 @@ export async function runReviewCompletion(client, card, result, config, worktree
401
419
  }
402
420
  // Move back to failColumn (To Do) for re-implementation
403
421
  await moveCardToColumn(client, card, config.review.failColumn);
422
+ const failureSummary = `Review rejected (cycle ${currentCycle}/${maxCycles}): ${criticalFindings.length} critical, ${majorFindings.length} major, ${minorFindings.length} minor`;
423
+ const recoveryBranch = branchName ?? undefined;
424
+ const recoveryUrl = branchName
425
+ ? getBranchWebUrl(branchName, worktreePath)
426
+ : null;
427
+ try {
428
+ await stateStore?.recordFailureSummary(card.id, {
429
+ summary: failureSummary,
430
+ reason: "review",
431
+ recoveryBranch,
432
+ });
433
+ }
434
+ catch (err) {
435
+ log.debug(TAG, `recordFailureSummary failed: ${err instanceof Error ? err.message : err}`);
436
+ }
437
+ if (recoveryBranch) {
438
+ log.info(TAG, `#${card.short_id} recovery branch ${recoveryBranch}${recoveryUrl ? ` (${recoveryUrl})` : ""}`);
439
+ }
404
440
  await client.endAgentSession(card.id, {
405
- status: "paused",
441
+ status: "failed",
442
+ failureReason: "review",
443
+ failureSummary,
444
+ recoveryBranch,
406
445
  ...buildTokenPayload(sessionStats),
407
446
  });
408
447
  log.info(TAG, `#${card.short_id} rejected (cycle ${currentCycle}/${maxCycles}) — moved to "${config.review.failColumn}"`);
@@ -191,12 +191,30 @@ export class ReviewWorker {
191
191
  if (!localMode) {
192
192
  log.info(this.tag, `Starting dev server on port ${port}...`);
193
193
  this.devServerProcess = spawnInGroup("bun", ["run", "dev", "--", "--port", String(port)], { cwd, stdio: ["ignore", "pipe", "pipe"] });
194
+ // Surface the dev-server warmup as `waiting` so the AgentTopBar
195
+ // doesn't sit on a stale `working` task while the server takes
196
+ // 10–20 seconds to boot.
197
+ await this.client.updateAgentProgress(card.id, {
198
+ agentIdentifier: agentIdentifier(this.id),
199
+ agentName: `${AGENT_NAME} (Review)`,
200
+ status: "waiting",
201
+ currentTask: `Starting dev server on port ${port}…`,
202
+ progressPercent: 10,
203
+ });
194
204
  // Invariant I2: review only proceeds with a proven-live dev server.
195
205
  // waitForDevServer rejects on timeout / exit; probeDevServer verifies
196
206
  // the port actually answers HTTP.
197
207
  await waitForDevServer(this.devServerProcess, 30_000);
198
208
  await probeDevServer(port);
199
209
  log.info(this.tag, `Dev server ready on port ${port}`);
210
+ // Restore active status now that the server is live.
211
+ await this.client.updateAgentProgress(card.id, {
212
+ agentIdentifier: agentIdentifier(this.id),
213
+ agentName: `${AGENT_NAME} (Review)`,
214
+ status: "working",
215
+ currentTask: "Reviewing changes",
216
+ progressPercent: 15,
217
+ });
200
218
  }
201
219
  if (this.aborted)
202
220
  return;
@@ -290,7 +308,7 @@ export class ReviewWorker {
290
308
  progressPercent: 80,
291
309
  });
292
310
  // Run review completion pipeline
293
- await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName, sessionStats, this.lastRunLogPath, this.workspaceId, this.sessionId);
311
+ await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName, sessionStats, this.lastRunLogPath, this.workspaceId, this.sessionId, this.stateStore);
294
312
  }
295
313
  catch (err) {
296
314
  this.state = "error";
@@ -18,6 +18,12 @@ export interface RunRecord {
18
18
  costCents: number;
19
19
  errorMessage?: string;
20
20
  }
21
+ export interface FailureSummaryRecord {
22
+ summary: string;
23
+ ts: number;
24
+ reason?: "verification" | "review" | "daemon_restart" | "budget" | "other";
25
+ recoveryBranch?: string;
26
+ }
21
27
  export interface CardRecord {
22
28
  cardId: string;
23
29
  attempts: number;
@@ -26,6 +32,7 @@ export interface CardRecord {
26
32
  dlqReason?: string;
27
33
  lastAttemptAt: number | null;
28
34
  lastOutcome: "success" | "failure" | null;
35
+ failureHistory?: FailureSummaryRecord[];
29
36
  }
30
37
  export declare function newRunId(): string;
31
38
  export declare function defaultStatePath(): string;
@@ -63,6 +70,15 @@ export declare class StateStore {
63
70
  getCard(cardId: string): CardRecord | null;
64
71
  incrementAttempt(cardId: string): Promise<number>;
65
72
  recordOutcome(cardId: string, outcome: "success" | "failure"): Promise<void>;
73
+ /**
74
+ * Push a failure summary onto the card's bounded history (most-recent first,
75
+ * capped at 5). Read back by DLQ comment formatting and the Agent History
76
+ * UI section to give users a post-mortem trail across attempts.
77
+ */
78
+ recordFailureSummary(cardId: string, entry: Omit<FailureSummaryRecord, "ts"> & {
79
+ ts?: number;
80
+ }): Promise<void>;
81
+ getRecentFailures(cardId: string, limit?: number): FailureSummaryRecord[];
66
82
  addCost(cardId: string, cents: number): Promise<void>;
67
83
  getDailyCostCents(date?: string): number;
68
84
  markDlq(cardId: string, reason: string): Promise<void>;
@@ -170,6 +170,29 @@ export class StateStore {
170
170
  rec.attempts = 0;
171
171
  await this.persist();
172
172
  }
173
+ /**
174
+ * Push a failure summary onto the card's bounded history (most-recent first,
175
+ * capped at 5). Read back by DLQ comment formatting and the Agent History
176
+ * UI section to give users a post-mortem trail across attempts.
177
+ */
178
+ async recordFailureSummary(cardId, entry) {
179
+ const rec = this.ensureCard(cardId);
180
+ const next = {
181
+ summary: entry.summary.slice(0, 500),
182
+ ts: entry.ts ?? Date.now(),
183
+ reason: entry.reason,
184
+ recoveryBranch: entry.recoveryBranch,
185
+ };
186
+ const existing = rec.failureHistory ?? [];
187
+ rec.failureHistory = [next, ...existing].slice(0, 5);
188
+ await this.persist();
189
+ }
190
+ getRecentFailures(cardId, limit = 3) {
191
+ const rec = this.getCard(cardId);
192
+ if (!rec?.failureHistory)
193
+ return [];
194
+ return rec.failureHistory.slice(0, limit);
195
+ }
173
196
  async addCost(cardId, cents) {
174
197
  if (cents <= 0)
175
198
  return;
package/dist/types.d.ts CHANGED
@@ -20,6 +20,12 @@ export interface AgentConfig {
20
20
  worktree: {
21
21
  basePath: string;
22
22
  baseBranch: string;
23
+ /** Remote-branch prefix while an attempt is still in-flight or failed. */
24
+ failedBranchPrefix: string;
25
+ /** Remote-branch prefix after a successful run reaches Review. */
26
+ approvedBranchPrefix: string;
27
+ /** Days to keep failed-attempt branches on origin before GC removes them. */
28
+ failedAttemptRetentionDays: number;
23
29
  };
24
30
  verification: {
25
31
  enabled: boolean;
package/dist/types.js CHANGED
@@ -18,6 +18,9 @@ export const DEFAULT_AGENT_CONFIG = {
18
18
  worktree: {
19
19
  basePath: ".harmony-worktrees",
20
20
  baseBranch: "main",
21
+ failedBranchPrefix: "agent-attempts/",
22
+ approvedBranchPrefix: "agent/",
23
+ failedAttemptRetentionDays: 7,
21
24
  },
22
25
  verification: {
23
26
  enabled: true,
@@ -12,7 +12,13 @@ export declare function runBuild(worktreePath: string, timeout: number): string[
12
12
  export declare function runLint(worktreePath: string, timeout: number): string[];
13
13
  export declare function runDeepReview(worktreePath: string, config: AgentConfig, workerId: number): Promise<string[]>;
14
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>;
15
+ export interface RecoveryInfo {
16
+ /** Remote ref where the failed attempt was pushed. */
17
+ branchName: string;
18
+ /** Public URL of the branch (GitHub/GitLab/Bitbucket tree view), if known. */
19
+ branchUrl: string | null;
20
+ }
21
+ export declare function reportFindings(client: HarmonyApiClient, cardId: string, result: VerificationResult, recovery?: RecoveryInfo | null): Promise<void>;
16
22
  export declare class DevServerReadinessError extends Error {
17
23
  constructor(message: string);
18
24
  }
@@ -160,8 +160,13 @@ export function attemptAutoFix(worktreePath, config, errors) {
160
160
  stdio: "pipe",
161
161
  });
162
162
  }
163
- export async function reportFindings(client, cardId, result) {
163
+ export async function reportFindings(client, cardId, result, recovery) {
164
164
  const items = [];
165
+ if (recovery) {
166
+ const cmd = `git fetch && git checkout ${recovery.branchName}`;
167
+ const url = recovery.branchUrl ? ` (${recovery.branchUrl})` : "";
168
+ items.push(`Recovery: \`${cmd}\`${url}`);
169
+ }
165
170
  for (const err of result.buildErrors) {
166
171
  items.push(`Build: ${err}`);
167
172
  }
package/dist/worker.js CHANGED
@@ -102,7 +102,7 @@ export class Worker {
102
102
  try {
103
103
  // --- PREPARING ---
104
104
  this.state = "preparing";
105
- this.branchName = makeBranchName(card.short_id, card.title);
105
+ this.branchName = makeBranchName(card.short_id, card.title, this.config.worktree.failedBranchPrefix);
106
106
  log.info(this.tag, `Preparing #${card.short_id} "${card.title}"`);
107
107
  // Per-card attempt counter resets on success; DLQ triggers off it.
108
108
  await this.stateStore.incrementAttempt(card.id);
@@ -195,7 +195,7 @@ export class Worker {
195
195
  });
196
196
  this.state = "completing";
197
197
  await this.recordPhase("completing");
198
- await runCompletion(this.client, card, this.branchName, this.worktreePath, this.config, this.id, this.lastSessionStats, this.workspaceId, this.sessionId);
198
+ await runCompletion(this.client, card, this.branchName, this.worktreePath, this.config, this.id, this.lastSessionStats, this.workspaceId, this.sessionId, this.stateStore);
199
199
  }
200
200
  catch (err) {
201
201
  this.state = "error";
@@ -1,4 +1,21 @@
1
1
  import type { StateStore } from "./state-store.js";
2
+ export interface RemoteBranchGcOptions {
3
+ /** Prefix to scan (e.g. `agent-attempts/`). */
4
+ prefix: string;
5
+ /** Retention in days. Branches older than this are removed. */
6
+ retentionDays: number;
7
+ /** Optional sync clock, for deterministic tests. */
8
+ now?: () => number;
9
+ }
10
+ export interface RemoteBranchGcResult {
11
+ scanned: number;
12
+ removed: string[];
13
+ skipped: string[];
14
+ errors: Array<{
15
+ ref: string;
16
+ error: string;
17
+ }>;
18
+ }
2
19
  export interface GcResult {
3
20
  checked: number;
4
21
  removed: string[];
@@ -27,12 +44,23 @@ export interface GcOptions {
27
44
  * Returns a summary; callers decide whether to log at info or warn.
28
45
  */
29
46
  export declare function runWorktreeGc(basePath: string, store: StateStore, opts?: GcOptions): GcResult;
47
+ /**
48
+ * Sweep stale failed-attempt branches off the remote.
49
+ *
50
+ * Lists `origin/<prefix>*`, asks git for each ref's committer timestamp via
51
+ * `for-each-ref`, and deletes anything older than `retentionDays`. Runs on
52
+ * the same GC tick that handles worktree directories — protecting unpushed
53
+ * commits is the job of the daemon (always push before verify); this sweep
54
+ * just keeps the namespace tidy.
55
+ */
56
+ export declare function pruneFailedRemoteBranches(opts: RemoteBranchGcOptions): RemoteBranchGcResult;
30
57
  export declare class WorktreeGc {
31
58
  private basePath;
32
59
  private store;
33
60
  private intervalMs;
61
+ private remoteOpts?;
34
62
  private timer;
35
- constructor(basePath: string, store: StateStore, intervalMs: number);
63
+ constructor(basePath: string, store: StateStore, intervalMs: number, remoteOpts?: RemoteBranchGcOptions | undefined);
36
64
  start(): void;
37
65
  stop(): void;
38
66
  private tick;
@@ -96,15 +96,114 @@ export function runWorktreeGc(basePath, store, opts = {}) {
96
96
  }
97
97
  return result;
98
98
  }
99
+ /**
100
+ * Sweep stale failed-attempt branches off the remote.
101
+ *
102
+ * Lists `origin/<prefix>*`, asks git for each ref's committer timestamp via
103
+ * `for-each-ref`, and deletes anything older than `retentionDays`. Runs on
104
+ * the same GC tick that handles worktree directories — protecting unpushed
105
+ * commits is the job of the daemon (always push before verify); this sweep
106
+ * just keeps the namespace tidy.
107
+ */
108
+ export function pruneFailedRemoteBranches(opts) {
109
+ const result = {
110
+ scanned: 0,
111
+ removed: [],
112
+ skipped: [],
113
+ errors: [],
114
+ };
115
+ if (!opts.prefix)
116
+ return result;
117
+ // 0 or negative retention is opt-out — caller wants nothing pruned.
118
+ if (!Number.isFinite(opts.retentionDays) || opts.retentionDays <= 0) {
119
+ return result;
120
+ }
121
+ const repoRoot = getRepoRoot();
122
+ if (!repoRoot) {
123
+ result.errors.push({ ref: "<repo-root>", error: "not a git repo" });
124
+ return result;
125
+ }
126
+ // Refresh the remote refs we know about. Pruned remote branches drop from
127
+ // local tracking, so we never try to delete refs the server already lost.
128
+ try {
129
+ execFileSync("git", ["fetch", "--prune", "origin"], {
130
+ cwd: repoRoot,
131
+ stdio: "pipe",
132
+ });
133
+ }
134
+ catch (err) {
135
+ result.errors.push({
136
+ ref: "fetch",
137
+ error: err instanceof Error ? err.message : String(err),
138
+ });
139
+ // Continue — for-each-ref may still find usable cached refs.
140
+ }
141
+ const refPattern = `refs/remotes/origin/${opts.prefix}*`;
142
+ let listing = "";
143
+ try {
144
+ listing = execFileSync("git", [
145
+ "for-each-ref",
146
+ "--format=%(refname:strip=3) %(committerdate:unix)",
147
+ refPattern,
148
+ ], { cwd: repoRoot, encoding: "utf-8" });
149
+ }
150
+ catch (err) {
151
+ result.errors.push({
152
+ ref: refPattern,
153
+ error: err instanceof Error ? err.message : String(err),
154
+ });
155
+ return result;
156
+ }
157
+ const cutoffSecs = (opts.now ?? Date.now)() / 1000 - opts.retentionDays * 24 * 60 * 60;
158
+ for (const line of listing.split("\n")) {
159
+ const trimmed = line.trim();
160
+ if (!trimmed)
161
+ continue;
162
+ const sp = trimmed.lastIndexOf(" ");
163
+ if (sp < 0)
164
+ continue;
165
+ const ref = trimmed.slice(0, sp);
166
+ const ts = Number(trimmed.slice(sp + 1));
167
+ result.scanned++;
168
+ if (!Number.isFinite(ts) || ts >= cutoffSecs) {
169
+ result.skipped.push(ref);
170
+ continue;
171
+ }
172
+ try {
173
+ execFileSync("git", ["push", "origin", `:refs/heads/${ref}`], {
174
+ cwd: repoRoot,
175
+ stdio: "pipe",
176
+ });
177
+ result.removed.push(ref);
178
+ }
179
+ catch (err) {
180
+ result.errors.push({
181
+ ref,
182
+ error: err instanceof Error ? err.message : String(err),
183
+ });
184
+ }
185
+ }
186
+ if (result.removed.length > 0) {
187
+ log.info(TAG, `Pruned ${result.removed.length} stale remote branch(es) under ${opts.prefix}: ${result.removed.join(", ")}`);
188
+ }
189
+ if (result.errors.length > 0) {
190
+ log.warn(TAG, `Remote branch GC had ${result.errors.length} error(s): ${result.errors
191
+ .map((e) => `${e.ref}: ${e.error}`)
192
+ .join("; ")}`);
193
+ }
194
+ return result;
195
+ }
99
196
  export class WorktreeGc {
100
197
  basePath;
101
198
  store;
102
199
  intervalMs;
200
+ remoteOpts;
103
201
  timer = null;
104
- constructor(basePath, store, intervalMs) {
202
+ constructor(basePath, store, intervalMs, remoteOpts) {
105
203
  this.basePath = basePath;
106
204
  this.store = store;
107
205
  this.intervalMs = intervalMs;
206
+ this.remoteOpts = remoteOpts;
108
207
  }
109
208
  start() {
110
209
  // Run once at startup, then on interval.
@@ -124,6 +223,14 @@ export class WorktreeGc {
124
223
  catch (err) {
125
224
  log.warn(TAG, `GC tick failed: ${err instanceof Error ? err.message : err}`);
126
225
  }
226
+ if (this.remoteOpts) {
227
+ try {
228
+ pruneFailedRemoteBranches(this.remoteOpts);
229
+ }
230
+ catch (err) {
231
+ log.warn(TAG, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
232
+ }
233
+ }
127
234
  }
128
235
  }
129
236
  function getRepoRoot() {
@@ -9,5 +9,10 @@ export declare function createWorktree(basePath: string, baseBranch: string, bra
9
9
  export declare function cleanupWorktree(worktreePath: string, branchName?: string): void;
10
10
  /**
11
11
  * Generate a branch name from a card's short ID and title.
12
+ *
13
+ * Agent branches start under the failedBranchPrefix (default `agent-attempts/`)
14
+ * and are renamed to the approvedBranchPrefix (default `agent/`) only after the
15
+ * Review pipeline approves them. Branches under `agent-attempts/` are pruned
16
+ * by the GC after `failedAttemptRetentionDays`.
12
17
  */
13
- export declare function makeBranchName(shortId: number, title: string): string;
18
+ export declare function makeBranchName(shortId: number, title: string, prefix?: string): string;
package/dist/worktree.js CHANGED
@@ -158,8 +158,13 @@ export function cleanupWorktree(worktreePath, branchName) {
158
158
  }
159
159
  /**
160
160
  * Generate a branch name from a card's short ID and title.
161
+ *
162
+ * Agent branches start under the failedBranchPrefix (default `agent-attempts/`)
163
+ * and are renamed to the approvedBranchPrefix (default `agent/`) only after the
164
+ * Review pipeline approves them. Branches under `agent-attempts/` are pruned
165
+ * by the GC after `failedAttemptRetentionDays`.
161
166
  */
162
- export function makeBranchName(shortId, title) {
167
+ export function makeBranchName(shortId, title, prefix = "agent-attempts/") {
163
168
  const slug = title
164
169
  .toLowerCase()
165
170
  .trim()
@@ -168,5 +173,5 @@ export function makeBranchName(shortId, title) {
168
173
  .replace(/-+/g, "-")
169
174
  .replace(/^-+|-+$/g, "")
170
175
  .slice(0, 40);
171
- return `agent/${shortId}-${slug || "task"}`;
176
+ return `${prefix}${shortId}-${slug || "task"}`;
172
177
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/agent",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Push-based agent daemon for Harmony — watches board assignments and spawns Claude CLI workers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",