@gethmy/agent 1.4.2 → 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/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;
@@ -98,3 +104,35 @@ export interface RealtimeCredentials {
98
104
  supabaseUrl: string;
99
105
  supabaseAnonKey: string;
100
106
  }
107
+ /** Pipeline that produced an episode. */
108
+ export type EpisodeKind = "implement" | "review";
109
+ /** Outcome of an implement run; review verdict maps to its own type. */
110
+ export type EpisodeOutcome = "success" | "failure";
111
+ /**
112
+ * Structured metadata persisted alongside every episode entity in
113
+ * `knowledge_entities.metadata`. Read by the recall path to render the
114
+ * "Similar past tasks" section in subsequent agent prompts.
115
+ */
116
+ export interface EpisodeMeta {
117
+ episode_kind: EpisodeKind;
118
+ card_short_id: number;
119
+ card_title: string;
120
+ approach_summary: string;
121
+ outcome: EpisodeOutcome;
122
+ quality_score: number;
123
+ duration_ms: number;
124
+ token_cost: {
125
+ input: number;
126
+ output: number;
127
+ usd: number;
128
+ };
129
+ files_touched: number;
130
+ num_turns: number;
131
+ error?: string;
132
+ /** Provenance only — never used as memory scope. */
133
+ agent_session_id?: string;
134
+ /** Set on back-fill from review pipeline. */
135
+ review_session_id?: string;
136
+ /** Set on review-decision entities so back-fill can find the original. */
137
+ original_episode_id?: string;
138
+ }
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);
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.4.2",
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",