@gethmy/agent 1.0.9 → 1.1.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.
Files changed (72) hide show
  1. package/README.md +67 -16
  2. package/dist/__tests__/budget.test.d.ts +1 -0
  3. package/dist/__tests__/budget.test.js +94 -0
  4. package/dist/__tests__/config-validation.test.d.ts +1 -0
  5. package/dist/__tests__/config-validation.test.js +65 -0
  6. package/dist/__tests__/dev-server-readiness.test.d.ts +1 -0
  7. package/dist/__tests__/dev-server-readiness.test.js +26 -0
  8. package/dist/__tests__/http-server.test.d.ts +1 -0
  9. package/dist/__tests__/http-server.test.js +115 -0
  10. package/dist/__tests__/log.test.d.ts +1 -0
  11. package/dist/__tests__/log.test.js +115 -0
  12. package/dist/__tests__/process-group.test.d.ts +1 -0
  13. package/dist/__tests__/process-group.test.js +68 -0
  14. package/dist/__tests__/reconcile-heartbeat.test.d.ts +1 -0
  15. package/dist/__tests__/reconcile-heartbeat.test.js +116 -0
  16. package/dist/__tests__/recovery.test.d.ts +1 -0
  17. package/dist/__tests__/recovery.test.js +126 -0
  18. package/dist/__tests__/review-parser.test.d.ts +1 -0
  19. package/dist/__tests__/review-parser.test.js +65 -0
  20. package/dist/__tests__/state-store.test.d.ts +1 -0
  21. package/dist/__tests__/state-store.test.js +132 -0
  22. package/dist/__tests__/transitions.test.d.ts +1 -0
  23. package/dist/__tests__/transitions.test.js +130 -0
  24. package/dist/__tests__/worktree-gc.test.d.ts +1 -0
  25. package/dist/__tests__/worktree-gc.test.js +137 -0
  26. package/dist/budget.d.ts +45 -0
  27. package/dist/budget.js +94 -0
  28. package/dist/cli.d.ts +15 -1
  29. package/dist/cli.js +239 -1
  30. package/dist/completion.d.ts +9 -0
  31. package/dist/completion.js +28 -2
  32. package/dist/config-validation.d.ts +18 -0
  33. package/dist/config-validation.js +66 -0
  34. package/dist/config.js +12 -0
  35. package/dist/http-server.d.ts +79 -0
  36. package/dist/http-server.js +115 -0
  37. package/dist/index.d.ts +4 -1
  38. package/dist/index.js +125 -10
  39. package/dist/log.d.ts +29 -5
  40. package/dist/log.js +80 -15
  41. package/dist/pool.d.ts +27 -2
  42. package/dist/pool.js +69 -4
  43. package/dist/process-group.d.ts +26 -0
  44. package/dist/process-group.js +72 -0
  45. package/dist/progress-tracker.js +2 -0
  46. package/dist/queue.d.ts +2 -0
  47. package/dist/queue.js +4 -0
  48. package/dist/reconcile.d.ts +15 -1
  49. package/dist/reconcile.js +63 -2
  50. package/dist/recovery.d.ts +30 -0
  51. package/dist/recovery.js +136 -0
  52. package/dist/review-completion.d.ts +12 -4
  53. package/dist/review-completion.js +158 -49
  54. package/dist/review-worker.d.ts +9 -2
  55. package/dist/review-worker.js +182 -78
  56. package/dist/run-log.d.ts +6 -0
  57. package/dist/run-log.js +19 -0
  58. package/dist/state-store.d.ts +72 -0
  59. package/dist/state-store.js +216 -0
  60. package/dist/transitions.d.ts +57 -0
  61. package/dist/transitions.js +131 -0
  62. package/dist/types.d.ts +23 -0
  63. package/dist/types.js +19 -1
  64. package/dist/verification.d.ts +17 -0
  65. package/dist/verification.js +71 -10
  66. package/dist/watcher.d.ts +2 -0
  67. package/dist/watcher.js +11 -0
  68. package/dist/worker.d.ts +9 -2
  69. package/dist/worker.js +168 -47
  70. package/dist/worktree-gc.d.ts +39 -0
  71. package/dist/worktree-gc.js +139 -0
  72. package/package.json +2 -2
@@ -0,0 +1,66 @@
1
+ import { log } from "./log.js";
2
+ const TAG = "config-validation";
3
+ export class ConfigValidationError extends Error {
4
+ issues;
5
+ constructor(message, issues) {
6
+ super(message);
7
+ this.issues = issues;
8
+ this.name = "ConfigValidationError";
9
+ }
10
+ }
11
+ function columnNames(board) {
12
+ return board.columns.map((c) => c.name);
13
+ }
14
+ function findColumn(board, name) {
15
+ const target = name.toLowerCase();
16
+ return board.columns.some((c) => c.name.toLowerCase() === target);
17
+ }
18
+ /**
19
+ * Validate that every column referenced in the agent config actually
20
+ * exists on the board. Labels that the daemon needs to create (agent,
21
+ * Ready to Merge, Merged, Need Review, agent-recovered) are not
22
+ * required to pre-exist — the daemon creates them on demand. Columns
23
+ * must already exist because moving a card to a non-existent column
24
+ * silently fails and leaves cards stuck.
25
+ *
26
+ * Fail-fast on any mismatch: the operator fixes their config OR the
27
+ * board before the daemon is useful.
28
+ */
29
+ export async function validateColumnReferences(client, projectId, config) {
30
+ const board = (await client.getBoard(projectId, {
31
+ summary: true,
32
+ }));
33
+ const known = columnNames(board);
34
+ const issues = [];
35
+ const required = [
36
+ ...config.pickupColumns.map((c) => ({ value: c, where: "pickupColumns" })),
37
+ {
38
+ value: config.completion.moveToColumn,
39
+ where: "completion.moveToColumn",
40
+ },
41
+ {
42
+ value: config.verification.failColumn,
43
+ where: "verification.failColumn",
44
+ },
45
+ ];
46
+ if (config.review.enabled) {
47
+ for (const c of config.review.pickupColumns) {
48
+ required.push({ value: c, where: "review.pickupColumns" });
49
+ }
50
+ required.push({ value: config.review.moveToColumn, where: "review.moveToColumn" }, { value: config.review.failColumn, where: "review.failColumn" });
51
+ }
52
+ for (const { value, where } of required) {
53
+ if (!value)
54
+ continue;
55
+ if (!findColumn(board, value)) {
56
+ issues.push(`${where}: column "${value}" not found on board`);
57
+ }
58
+ }
59
+ if (issues.length > 0) {
60
+ const help = `Available columns: ${known.join(", ")}`;
61
+ throw new ConfigValidationError(`Invalid agent config — the following columns are missing:\n - ${issues.join("\n - ")}\n${help}`, issues);
62
+ }
63
+ log.info(TAG, `Validated columns: ${Array.from(new Set(required.map((r) => r.value)))
64
+ .filter(Boolean)
65
+ .join(", ")}`);
66
+ }
package/dist/config.js CHANGED
@@ -70,6 +70,18 @@ export function loadDaemonConfig() {
70
70
  ...DEFAULT_AGENT_CONFIG.review,
71
71
  ...(agentOverrides.review ?? {}),
72
72
  },
73
+ budget: {
74
+ ...DEFAULT_AGENT_CONFIG.budget,
75
+ ...(agentOverrides.budget ?? {}),
76
+ },
77
+ http: {
78
+ ...DEFAULT_AGENT_CONFIG.http,
79
+ ...(agentOverrides.http ?? {}),
80
+ },
81
+ timing: {
82
+ ...DEFAULT_AGENT_CONFIG.timing,
83
+ ...(agentOverrides.timing ?? {}),
84
+ },
73
85
  };
74
86
  return { apiKey, apiUrl, workspaceId, projectId, userEmail, agent };
75
87
  }
@@ -0,0 +1,79 @@
1
+ export interface HealthSnapshot {
2
+ healthy: boolean;
3
+ checks: Record<string, boolean>;
4
+ }
5
+ export interface WorkerSnapshot {
6
+ id: number;
7
+ pipeline: "implement" | "review";
8
+ state: string;
9
+ cardId: string | null;
10
+ cardShortId: number | null;
11
+ phase: string | null;
12
+ startedAt: number | null;
13
+ branchName: string | null;
14
+ }
15
+ export interface StatusSnapshot {
16
+ daemonId: string;
17
+ daemonPid: number;
18
+ startedAt: number;
19
+ uptimeMs: number;
20
+ workers: WorkerSnapshot[];
21
+ implQueue: Array<{
22
+ cardId: string;
23
+ shortId: number;
24
+ priority: number;
25
+ enqueuedAt: number;
26
+ }>;
27
+ reviewQueue: Array<{
28
+ cardId: string;
29
+ shortId: number;
30
+ priority: number;
31
+ enqueuedAt: number;
32
+ }>;
33
+ dlq: Array<{
34
+ cardId: string;
35
+ reason: string;
36
+ attempts: number;
37
+ totalCostCents: number;
38
+ }>;
39
+ budget: {
40
+ todayCents: number;
41
+ dailyCapCents: number;
42
+ };
43
+ }
44
+ export type Command = "pause" | "resume" | "stop";
45
+ export interface HttpServerOptions {
46
+ port: number;
47
+ bindAddr: string;
48
+ getStatus: () => StatusSnapshot;
49
+ getHealth: () => HealthSnapshot;
50
+ handleCommand: (cmd: Command, cardId: string) => Promise<void>;
51
+ /**
52
+ * Clear a card's DLQ marker. Routed through the daemon (not CLI
53
+ * direct-write) so we never have two processes racing to persist
54
+ * the same state-store file.
55
+ */
56
+ clearDlq: (cardId: string) => Promise<void>;
57
+ }
58
+ /**
59
+ * Tiny introspection + control surface for the running daemon.
60
+ * Binds to 127.0.0.1 by default so it's not network-reachable.
61
+ *
62
+ * GET /health — 200 when healthy, 503 otherwise. Simple liveness check
63
+ * suitable for process supervisors (systemd, pm2, docker healthcheck).
64
+ * GET /status — full JSON snapshot: workers, queues, DLQ, budget.
65
+ * POST /pause/:cardId, /resume/:cardId, /stop/:cardId — command path
66
+ * so an operator can nudge a stuck worker without killing the daemon.
67
+ */
68
+ export declare class HttpServer {
69
+ private opts;
70
+ private server;
71
+ constructor(opts: HttpServerOptions);
72
+ start(): Promise<void>;
73
+ stop(): Promise<void>;
74
+ private route;
75
+ private respondDlqClear;
76
+ private respondHealth;
77
+ private respondStatus;
78
+ private respondCommand;
79
+ }
@@ -0,0 +1,115 @@
1
+ import { createServer, } from "node:http";
2
+ import { log } from "./log.js";
3
+ const TAG = "http";
4
+ /**
5
+ * Tiny introspection + control surface for the running daemon.
6
+ * Binds to 127.0.0.1 by default so it's not network-reachable.
7
+ *
8
+ * GET /health — 200 when healthy, 503 otherwise. Simple liveness check
9
+ * suitable for process supervisors (systemd, pm2, docker healthcheck).
10
+ * GET /status — full JSON snapshot: workers, queues, DLQ, budget.
11
+ * POST /pause/:cardId, /resume/:cardId, /stop/:cardId — command path
12
+ * so an operator can nudge a stuck worker without killing the daemon.
13
+ */
14
+ export class HttpServer {
15
+ opts;
16
+ server = null;
17
+ constructor(opts) {
18
+ this.opts = opts;
19
+ }
20
+ async start() {
21
+ return new Promise((resolve, reject) => {
22
+ this.server = createServer((req, res) => {
23
+ this.route(req, res).catch((err) => {
24
+ log.error(TAG, `unhandled: ${err instanceof Error ? err.message : err}`);
25
+ if (!res.headersSent) {
26
+ res.writeHead(500, { "content-type": "application/json" });
27
+ res.end(JSON.stringify({ error: "internal_error" }));
28
+ }
29
+ });
30
+ });
31
+ this.server.once("error", reject);
32
+ this.server.listen(this.opts.port, this.opts.bindAddr, () => {
33
+ log.info(TAG, `listening on http://${this.opts.bindAddr}:${this.opts.port}`);
34
+ resolve();
35
+ });
36
+ });
37
+ }
38
+ async stop() {
39
+ if (!this.server)
40
+ return;
41
+ await new Promise((resolve) => {
42
+ this.server?.close(() => resolve());
43
+ });
44
+ this.server = null;
45
+ }
46
+ async route(req, res) {
47
+ const url = new URL(req.url ?? "/", "http://localhost");
48
+ const method = (req.method ?? "GET").toUpperCase();
49
+ const path = url.pathname;
50
+ if (method === "GET" && path === "/health") {
51
+ return this.respondHealth(res);
52
+ }
53
+ if (method === "GET" && path === "/status") {
54
+ return this.respondStatus(res);
55
+ }
56
+ if (method === "POST") {
57
+ const dlq = path.match(/^\/dlq\/clear\/([^/]+)$/);
58
+ if (dlq) {
59
+ return this.respondDlqClear(res, decodeURIComponent(dlq[1]));
60
+ }
61
+ const cmd = parseCommand(path);
62
+ if (cmd) {
63
+ return this.respondCommand(res, cmd.command, cmd.cardId);
64
+ }
65
+ }
66
+ res.writeHead(404, { "content-type": "application/json" });
67
+ res.end(JSON.stringify({ error: "not_found", path }));
68
+ }
69
+ async respondDlqClear(res, cardId) {
70
+ try {
71
+ await this.opts.clearDlq(cardId);
72
+ res.writeHead(200, { "content-type": "application/json" });
73
+ res.end(JSON.stringify({ ok: true, cardId }));
74
+ }
75
+ catch (err) {
76
+ res.writeHead(500, { "content-type": "application/json" });
77
+ res.end(JSON.stringify({
78
+ error: "clear_dlq_failed",
79
+ detail: err instanceof Error ? err.message : String(err),
80
+ }));
81
+ }
82
+ }
83
+ respondHealth(res) {
84
+ const health = this.opts.getHealth();
85
+ res.writeHead(health.healthy ? 200 : 503, {
86
+ "content-type": "application/json",
87
+ });
88
+ res.end(JSON.stringify(health));
89
+ }
90
+ respondStatus(res) {
91
+ const snapshot = this.opts.getStatus();
92
+ res.writeHead(200, { "content-type": "application/json" });
93
+ res.end(JSON.stringify(snapshot));
94
+ }
95
+ async respondCommand(res, command, cardId) {
96
+ try {
97
+ await this.opts.handleCommand(command, cardId);
98
+ res.writeHead(200, { "content-type": "application/json" });
99
+ res.end(JSON.stringify({ ok: true, command, cardId }));
100
+ }
101
+ catch (err) {
102
+ res.writeHead(500, { "content-type": "application/json" });
103
+ res.end(JSON.stringify({
104
+ error: "command_failed",
105
+ detail: err instanceof Error ? err.message : String(err),
106
+ }));
107
+ }
108
+ }
109
+ }
110
+ function parseCommand(path) {
111
+ const match = path.match(/^\/(pause|resume|stop)\/([^/]+)$/);
112
+ if (!match)
113
+ return null;
114
+ return { command: match[1], cardId: decodeURIComponent(match[2]) };
115
+ }
package/dist/index.d.ts CHANGED
@@ -1 +1,4 @@
1
- export {};
1
+ import { type DaemonConfig } from "./config.js";
2
+ declare function validatePrerequisites(config: DaemonConfig): Promise<string>;
3
+ export { validatePrerequisites };
4
+ export declare function main(): Promise<void>;
package/dist/index.js CHANGED
@@ -1,12 +1,19 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { buildLabelMap, hasLabel, resolveCardLabels } from "./board-helpers.js";
3
4
  import { createApiClient, fetchRealtimeCredentials, loadDaemonConfig, } from "./config.js";
5
+ import { ConfigValidationError, validateColumnReferences, } from "./config-validation.js";
4
6
  import { detectGitProvider, validateGitProviderCli } from "./git-pr.js";
7
+ import { HttpServer } from "./http-server.js";
5
8
  import { log } from "./log.js";
6
9
  import { MergeMonitor } from "./merge-monitor.js";
7
10
  import { Pool } from "./pool.js";
8
11
  import { Reconciler } from "./reconcile.js";
12
+ import { recoverOrphans } from "./recovery.js";
13
+ import { extractBranchFromDescription } from "./review-worktree.js";
14
+ import { StateStore } from "./state-store.js";
9
15
  import { Watcher } from "./watcher.js";
16
+ import { WorktreeGc } from "./worktree-gc.js";
10
17
  const TAG = "daemon";
11
18
  // ============ STARTUP VALIDATION ============
12
19
  async function validatePrerequisites(config) {
@@ -70,7 +77,8 @@ async function validatePrerequisites(config) {
70
77
  return agentMember.userId;
71
78
  }
72
79
  // ============ MAIN DAEMON ============
73
- async function main() {
80
+ export { validatePrerequisites };
81
+ export async function main() {
74
82
  log.info(TAG, "Harmony Agent Daemon starting...");
75
83
  // Load config
76
84
  const config = loadDaemonConfig();
@@ -83,11 +91,32 @@ async function main() {
83
91
  log.info(TAG, `Agent user: ${config.userEmail} (${agentUserId})`);
84
92
  // Create API client
85
93
  const client = createApiClient(config);
94
+ // Fail fast if configured columns don't exist on the board. A stuck
95
+ // card with no log output is worse than a clear startup error.
96
+ try {
97
+ await validateColumnReferences(client, config.projectId, config.agent);
98
+ }
99
+ catch (err) {
100
+ if (err instanceof ConfigValidationError) {
101
+ log.error(TAG, err.message);
102
+ process.exit(1);
103
+ }
104
+ throw err;
105
+ }
106
+ // Durable run/card state lives outside the process. Recover orphans
107
+ // from any crashed prior daemon BEFORE we start accepting new work.
108
+ const stateStore = StateStore.open();
109
+ const daemonId = randomUUID();
110
+ await stateStore.setDaemon(daemonId, process.pid);
111
+ const outcomes = await recoverOrphans(stateStore, client, config.agent);
112
+ if (outcomes.length > 0) {
113
+ log.info(TAG, `recovery: ${outcomes.length} orphan(s) handled, ${outcomes.filter((o) => o.errors.length).length} had errors`);
114
+ }
86
115
  // Fetch realtime credentials
87
116
  const realtimeCreds = await fetchRealtimeCredentials(client);
88
117
  log.info(TAG, "Realtime credentials fetched");
89
118
  // Create pool
90
- const pool = new Pool(config.agent, client, config.userEmail, config.workspaceId, config.projectId);
119
+ const pool = new Pool(config.agent, client, config.userEmail, config.workspaceId, config.projectId, stateStore);
91
120
  // Create reconciler
92
121
  const reviewColumns = config.agent.review.enabled
93
122
  ? config.agent.review.pickupColumns
@@ -95,12 +124,74 @@ async function main() {
95
124
  const approvedLabel = config.agent.review.enabled
96
125
  ? config.agent.review.approvedLabel
97
126
  : "";
98
- const reconciler = new Reconciler(client, pool, config.projectId, agentUserId, config.agent.pickupColumns, reviewColumns, approvedLabel, 60_000);
127
+ const reconciler = new Reconciler(client, pool, config.projectId, agentUserId, config.agent.pickupColumns, reviewColumns, approvedLabel, config.agent.timing.reconcileIntervalMs, stateStore, config.agent);
99
128
  // Create merge monitor (polls PR merge status for "Ready to Merge" cards)
100
129
  let mergeMonitor = null;
101
130
  if (config.agent.review.enabled && config.agent.review.mergeMonitor) {
102
131
  mergeMonitor = new MergeMonitor(client, config.projectId, config.agent);
103
132
  }
133
+ // Periodic worktree GC — removes orphan worktrees older than 1h that
134
+ // no active run claims. Covers crashes that left worktrees behind.
135
+ const worktreeGc = new WorktreeGc(config.agent.worktree.basePath, stateStore, config.agent.timing.worktreeGcIntervalMs);
136
+ // Local status/control HTTP server.
137
+ const startedAt = Date.now();
138
+ const httpServer = config.agent.http.enabled
139
+ ? new HttpServer({
140
+ port: config.agent.http.port,
141
+ bindAddr: config.agent.http.bindAddr,
142
+ getHealth: () => {
143
+ const lastTick = reconciler.lastTick;
144
+ const reconcilerFresh = lastTick !== null &&
145
+ Date.now() - lastTick < config.agent.timing.reconcileIntervalMs * 2;
146
+ const checks = {
147
+ watcher: watcher.isConnected,
148
+ reconciler: reconciler.isRunning && reconcilerFresh,
149
+ };
150
+ return {
151
+ healthy: Object.values(checks).every(Boolean),
152
+ checks,
153
+ };
154
+ },
155
+ getStatus: () => {
156
+ const queues = pool.snapshotQueues();
157
+ const dlq = stateStore.listDlq().map((c) => ({
158
+ cardId: c.cardId,
159
+ reason: c.dlqReason ?? "unknown",
160
+ attempts: c.attempts,
161
+ totalCostCents: c.totalCostCents,
162
+ }));
163
+ return {
164
+ daemonId,
165
+ daemonPid: process.pid,
166
+ startedAt,
167
+ uptimeMs: Date.now() - startedAt,
168
+ workers: pool.snapshotWorkers().map((w) => ({
169
+ ...w,
170
+ phase: null,
171
+ })),
172
+ implQueue: queues.impl.map((i) => ({
173
+ cardId: i.cardId,
174
+ shortId: i.shortId,
175
+ priority: i.priority,
176
+ enqueuedAt: i.enqueuedAt,
177
+ })),
178
+ reviewQueue: queues.review.map((i) => ({
179
+ cardId: i.cardId,
180
+ shortId: i.shortId,
181
+ priority: i.priority,
182
+ enqueuedAt: i.enqueuedAt,
183
+ })),
184
+ dlq,
185
+ budget: {
186
+ todayCents: stateStore.getDailyCostCents(),
187
+ dailyCapCents: config.agent.budget.dailyBudgetCents,
188
+ },
189
+ };
190
+ },
191
+ handleCommand: (cmd, cardId) => pool.handleAgentCommand(cardId, cmd),
192
+ clearDlq: (cardId) => stateStore.clearDlq(cardId),
193
+ })
194
+ : null;
104
195
  // Create watcher (broadcast events from harmony-api + agent commands from UI)
105
196
  const watcher = new Watcher(realtimeCreds, config.projectId, async (event) => {
106
197
  await handleBroadcast(event, client, pool, config, agentUserId);
@@ -109,6 +200,7 @@ async function main() {
109
200
  });
110
201
  // Wire up shutdown
111
202
  let shuttingDown = false;
203
+ let exitCode = 0;
112
204
  const shutdown = async (signal) => {
113
205
  if (shuttingDown)
114
206
  return;
@@ -116,17 +208,38 @@ async function main() {
116
208
  log.info(TAG, `Received ${signal}, shutting down gracefully...`);
117
209
  reconciler.stop();
118
210
  mergeMonitor?.stop();
211
+ worktreeGc.stop();
212
+ await httpServer?.stop();
119
213
  await watcher.stop();
120
214
  await pool.shutdown();
121
215
  log.info(TAG, "Daemon stopped.");
122
- process.exit(0);
216
+ process.exit(exitCode);
123
217
  };
124
218
  process.on("SIGINT", () => shutdown("SIGINT"));
125
219
  process.on("SIGTERM", () => shutdown("SIGTERM"));
220
+ process.on("uncaughtException", (err) => {
221
+ log.error(TAG, `Uncaught exception: ${err.message}`);
222
+ exitCode = 1;
223
+ shutdown("uncaughtException");
224
+ });
225
+ process.on("unhandledRejection", (reason) => {
226
+ log.error(TAG, `Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
227
+ exitCode = 1;
228
+ shutdown("unhandledRejection");
229
+ });
126
230
  // Start everything
127
231
  await watcher.start();
128
232
  reconciler.start();
129
233
  mergeMonitor?.start();
234
+ worktreeGc.start();
235
+ if (httpServer) {
236
+ try {
237
+ await httpServer.start();
238
+ }
239
+ catch (err) {
240
+ log.warn(TAG, `HTTP server failed to bind: ${err instanceof Error ? err.message : err}`);
241
+ }
242
+ }
130
243
  log.info(TAG, "Daemon is running. Watching for card assignments...");
131
244
  }
132
245
  // ============ EVENT HANDLER ============
@@ -190,10 +303,12 @@ async function tryEnqueueCard(cardId, client, pool, config) {
190
303
  log.debug(TAG, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
191
304
  return;
192
305
  }
193
- pool.enqueue(card, column, cardLabels, subtasks, mode);
306
+ // Skip review for cards without a branch reference — not qualified for auto-review
307
+ if (mode === "review" && !extractBranchFromDescription(card.description)) {
308
+ log.info(TAG, `Card #${card.short_id} has no branch reference — skipping auto-review`);
309
+ return;
310
+ }
311
+ await pool.enqueue(card, column, cardLabels, subtasks, mode);
194
312
  }
195
- // ============ RUN ============
196
- main().catch((err) => {
197
- log.error(TAG, `Fatal: ${err instanceof Error ? err.message : err}`);
198
- process.exit(1);
199
- });
313
+ // The daemon is now launched from cli.ts so other subcommands
314
+ // (status, doctor, gc, dlq) can load this module without side effects.
package/dist/log.d.ts CHANGED
@@ -1,10 +1,34 @@
1
1
  /**
2
2
  * Structured logging for the agent daemon.
3
- * Always logs to stderr so stdout stays clean for potential piping.
3
+ *
4
+ * When stderr is a TTY, output is ANSI-coloured single-line text — the
5
+ * readable default for humans running the daemon interactively. When
6
+ * stderr is piped/redirected (files, logshippers, systemd), output
7
+ * falls back to one JSON object per line — trivially `jq`-able,
8
+ * indexable, and machine-readable.
9
+ *
10
+ * Overrides: `--pretty` / HARMONY_AGENT_PRETTY=1 force pretty;
11
+ * `--json` / HARMONY_AGENT_JSON=1 force JSON. Explicit flags beat
12
+ * TTY detection either way.
13
+ *
14
+ * Stdout is left clean so consumers can pipe structured data if they
15
+ * want to (future use).
4
16
  */
17
+ export interface LogContext {
18
+ event?: string;
19
+ runId?: string;
20
+ cardId?: string;
21
+ [key: string]: unknown;
22
+ }
5
23
  export declare const log: {
6
- info(tag: string, msg: string): void;
7
- warn(tag: string, msg: string): void;
8
- error(tag: string, msg: string): void;
9
- debug(tag: string, msg: string): void;
24
+ info(tag: string, msg: string, ctx?: LogContext): void;
25
+ warn(tag: string, msg: string, ctx?: LogContext): void;
26
+ error(tag: string, msg: string, ctx?: LogContext): void;
27
+ debug(tag: string, msg: string, ctx?: LogContext): void;
28
+ /**
29
+ * Emit a named event. Semantically the same as `info` but signals to
30
+ * downstream tooling that this is a structured event worth indexing.
31
+ */
32
+ event(tag: string, event: string, ctx?: LogContext): void;
10
33
  };
34
+ export declare function isPretty(): boolean;
package/dist/log.js CHANGED
@@ -1,6 +1,18 @@
1
1
  /**
2
2
  * Structured logging for the agent daemon.
3
- * Always logs to stderr so stdout stays clean for potential piping.
3
+ *
4
+ * When stderr is a TTY, output is ANSI-coloured single-line text — the
5
+ * readable default for humans running the daemon interactively. When
6
+ * stderr is piped/redirected (files, logshippers, systemd), output
7
+ * falls back to one JSON object per line — trivially `jq`-able,
8
+ * indexable, and machine-readable.
9
+ *
10
+ * Overrides: `--pretty` / HARMONY_AGENT_PRETTY=1 force pretty;
11
+ * `--json` / HARMONY_AGENT_JSON=1 force JSON. Explicit flags beat
12
+ * TTY detection either way.
13
+ *
14
+ * Stdout is left clean so consumers can pipe structured data if they
15
+ * want to (future use).
4
16
  */
5
17
  const COLORS = {
6
18
  reset: "\x1b[0m",
@@ -11,25 +23,78 @@ const COLORS = {
11
23
  blue: "\x1b[34m",
12
24
  cyan: "\x1b[36m",
13
25
  };
14
- function timestamp() {
15
- return new Date().toISOString().slice(11, 23);
26
+ const LEVEL_COLOR = {
27
+ debug: COLORS.dim,
28
+ info: COLORS.green,
29
+ warn: COLORS.yellow,
30
+ error: COLORS.red,
31
+ };
32
+ function pretty() {
33
+ if (process.env.HARMONY_AGENT_JSON === "1" ||
34
+ process.argv.includes("--json")) {
35
+ return false;
36
+ }
37
+ if (process.env.HARMONY_AGENT_PRETTY === "1")
38
+ return true;
39
+ if (process.argv.includes("--pretty"))
40
+ return true;
41
+ return Boolean(process.stderr.isTTY);
42
+ }
43
+ function shortTime(iso) {
44
+ return iso.slice(11, 23);
45
+ }
46
+ function emit(rec) {
47
+ if (rec.level === "debug" && !process.env.DEBUG)
48
+ return;
49
+ if (pretty()) {
50
+ const color = LEVEL_COLOR[rec.level];
51
+ const label = rec.level.toUpperCase().padEnd(5, " ");
52
+ const ctx = [];
53
+ if (rec.event)
54
+ ctx.push(`event=${rec.event}`);
55
+ if (rec.runId)
56
+ ctx.push(`run=${rec.runId}`);
57
+ if (rec.cardId)
58
+ ctx.push(`card=${rec.cardId}`);
59
+ const tail = ctx.length
60
+ ? ` ${COLORS.dim}(${ctx.join(" ")})${COLORS.reset}`
61
+ : "";
62
+ process.stderr.write(`${COLORS.dim}${shortTime(rec.ts)}${COLORS.reset} ${color}${label}${COLORS.reset} ${COLORS.cyan}[${rec.tag}]${COLORS.reset} ${rec.msg}${tail}\n`);
63
+ return;
64
+ }
65
+ process.stderr.write(`${JSON.stringify(rec)}\n`);
16
66
  }
17
- function fmt(level, color, tag, msg) {
18
- return `${COLORS.dim}${timestamp()}${COLORS.reset} ${color}${level}${COLORS.reset} ${COLORS.cyan}[${tag}]${COLORS.reset} ${msg}`;
67
+ function record(level, tag, msg, ctx) {
68
+ const rec = {
69
+ ts: new Date().toISOString(),
70
+ level,
71
+ tag,
72
+ msg,
73
+ ...(ctx ?? {}),
74
+ };
75
+ emit(rec);
19
76
  }
20
77
  export const log = {
21
- info(tag, msg) {
22
- console.error(fmt("INFO ", COLORS.green, tag, msg));
78
+ info(tag, msg, ctx) {
79
+ record("info", tag, msg, ctx);
23
80
  },
24
- warn(tag, msg) {
25
- console.error(fmt("WARN ", COLORS.yellow, tag, msg));
81
+ warn(tag, msg, ctx) {
82
+ record("warn", tag, msg, ctx);
26
83
  },
27
- error(tag, msg) {
28
- console.error(fmt("ERROR", COLORS.red, tag, msg));
84
+ error(tag, msg, ctx) {
85
+ record("error", tag, msg, ctx);
29
86
  },
30
- debug(tag, msg) {
31
- if (process.env.DEBUG) {
32
- console.error(fmt("DEBUG", COLORS.dim, tag, msg));
33
- }
87
+ debug(tag, msg, ctx) {
88
+ record("debug", tag, msg, ctx);
89
+ },
90
+ /**
91
+ * Emit a named event. Semantically the same as `info` but signals to
92
+ * downstream tooling that this is a structured event worth indexing.
93
+ */
94
+ event(tag, event, ctx) {
95
+ record("info", tag, event, { ...ctx, event });
34
96
  },
35
97
  };
98
+ export function isPretty() {
99
+ return pretty();
100
+ }