@gethmy/agent 1.0.8 → 1.1.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.
Files changed (73) 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__/progress-tracker.test.js +2 -1
  15. package/dist/__tests__/reconcile-heartbeat.test.d.ts +1 -0
  16. package/dist/__tests__/reconcile-heartbeat.test.js +116 -0
  17. package/dist/__tests__/recovery.test.d.ts +1 -0
  18. package/dist/__tests__/recovery.test.js +126 -0
  19. package/dist/__tests__/review-parser.test.d.ts +1 -0
  20. package/dist/__tests__/review-parser.test.js +65 -0
  21. package/dist/__tests__/state-store.test.d.ts +1 -0
  22. package/dist/__tests__/state-store.test.js +132 -0
  23. package/dist/__tests__/transitions.test.d.ts +1 -0
  24. package/dist/__tests__/transitions.test.js +130 -0
  25. package/dist/__tests__/worktree-gc.test.d.ts +1 -0
  26. package/dist/__tests__/worktree-gc.test.js +137 -0
  27. package/dist/budget.d.ts +45 -0
  28. package/dist/budget.js +94 -0
  29. package/dist/cli.d.ts +15 -1
  30. package/dist/cli.js +239 -1
  31. package/dist/completion.d.ts +9 -0
  32. package/dist/completion.js +28 -2
  33. package/dist/config-validation.d.ts +18 -0
  34. package/dist/config-validation.js +66 -0
  35. package/dist/config.js +12 -0
  36. package/dist/http-server.d.ts +79 -0
  37. package/dist/http-server.js +115 -0
  38. package/dist/index.d.ts +4 -1
  39. package/dist/index.js +125 -10
  40. package/dist/log.d.ts +29 -5
  41. package/dist/log.js +80 -15
  42. package/dist/pool.d.ts +27 -2
  43. package/dist/pool.js +69 -4
  44. package/dist/process-group.d.ts +26 -0
  45. package/dist/process-group.js +72 -0
  46. package/dist/progress-tracker.js +3 -1
  47. package/dist/queue.d.ts +2 -0
  48. package/dist/queue.js +4 -0
  49. package/dist/reconcile.d.ts +15 -1
  50. package/dist/reconcile.js +63 -2
  51. package/dist/recovery.d.ts +30 -0
  52. package/dist/recovery.js +136 -0
  53. package/dist/review-completion.d.ts +12 -4
  54. package/dist/review-completion.js +158 -49
  55. package/dist/review-worker.d.ts +9 -2
  56. package/dist/review-worker.js +182 -78
  57. package/dist/run-log.d.ts +6 -0
  58. package/dist/run-log.js +19 -0
  59. package/dist/state-store.d.ts +72 -0
  60. package/dist/state-store.js +216 -0
  61. package/dist/transitions.d.ts +57 -0
  62. package/dist/transitions.js +131 -0
  63. package/dist/types.d.ts +23 -0
  64. package/dist/types.js +19 -1
  65. package/dist/verification.d.ts +17 -0
  66. package/dist/verification.js +71 -10
  67. package/dist/watcher.d.ts +2 -0
  68. package/dist/watcher.js +11 -0
  69. package/dist/worker.d.ts +9 -2
  70. package/dist/worker.js +168 -47
  71. package/dist/worktree-gc.d.ts +39 -0
  72. package/dist/worktree-gc.js +139 -0
  73. package/package.json +1 -1
@@ -0,0 +1,137 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ vi.mock("../worktree.js", () => ({
7
+ cleanupWorktree: vi.fn((path) => {
8
+ // Make cleanupWorktree actually remove the directory so our
9
+ // assertions reflect reality without invoking git.
10
+ rmSync(path, { recursive: true, force: true });
11
+ }),
12
+ }));
13
+ import { newRunId, StateStore } from "../state-store.js";
14
+ import { cleanupWorktree } from "../worktree.js";
15
+ import { runWorktreeGc } from "../worktree-gc.js";
16
+ function initRepo(dir) {
17
+ execFileSync("git", ["init", "-q", dir], { stdio: "pipe" });
18
+ execFileSync("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", "init"], {
19
+ stdio: "pipe",
20
+ env: {
21
+ ...process.env,
22
+ GIT_AUTHOR_NAME: "t",
23
+ GIT_AUTHOR_EMAIL: "t@t",
24
+ GIT_COMMITTER_NAME: "t",
25
+ GIT_COMMITTER_EMAIL: "t@t",
26
+ },
27
+ });
28
+ }
29
+ describe("runWorktreeGc", () => {
30
+ let repoDir;
31
+ let store;
32
+ beforeEach(() => {
33
+ // Realpath to collapse the /tmp → /private/tmp symlink on macOS so
34
+ // paths match what `git rev-parse --show-toplevel` returns.
35
+ repoDir = realpathSync(mkdtempSync(join(tmpdir(), "gc-repo-")));
36
+ initRepo(repoDir);
37
+ const stateDir = mkdtempSync(join(tmpdir(), "gc-state-"));
38
+ store = new StateStore(join(stateDir, "state.json"));
39
+ vi.clearAllMocks();
40
+ });
41
+ afterEach(() => {
42
+ rmSync(repoDir, { recursive: true, force: true });
43
+ });
44
+ function makeWorktreeDir(name, ageMs = 0) {
45
+ const dir = join(repoDir, ".harmony-worktrees", name);
46
+ mkdirSync(dir, { recursive: true });
47
+ writeFileSync(join(dir, "marker"), "x");
48
+ if (ageMs > 0) {
49
+ const mtime = new Date(Date.now() - ageMs);
50
+ execFileSync("touch", ["-m", "-t", formatMtime(mtime), dir]);
51
+ }
52
+ return dir;
53
+ }
54
+ function formatMtime(d) {
55
+ const pad = (n) => String(n).padStart(2, "0");
56
+ return (d.getFullYear().toString() +
57
+ pad(d.getMonth() + 1) +
58
+ pad(d.getDate()) +
59
+ pad(d.getHours()) +
60
+ pad(d.getMinutes()));
61
+ }
62
+ it("removes orphan worktrees older than minAgeMs", () => {
63
+ const cwd = process.cwd();
64
+ process.chdir(repoDir);
65
+ try {
66
+ makeWorktreeDir("agent-42-old", 2 * 60 * 60 * 1000);
67
+ const result = runWorktreeGc(".harmony-worktrees", store, {
68
+ minAgeMs: 60 * 60 * 1000,
69
+ });
70
+ expect(result.removed).toHaveLength(1);
71
+ expect(cleanupWorktree).toHaveBeenCalledTimes(1);
72
+ }
73
+ finally {
74
+ process.chdir(cwd);
75
+ }
76
+ });
77
+ it("keeps worktrees that match an active run", async () => {
78
+ const cwd = process.cwd();
79
+ process.chdir(repoDir);
80
+ try {
81
+ const wtPath = makeWorktreeDir("agent-99", 2 * 60 * 60 * 1000);
82
+ await store.insertRun({
83
+ runId: newRunId(),
84
+ cardId: "c1",
85
+ cardShortId: 99,
86
+ pipeline: "implement",
87
+ workerId: 0,
88
+ sessionId: null,
89
+ worktreePath: wtPath,
90
+ branchName: "agent/99",
91
+ daemonPid: process.pid,
92
+ phase: "running",
93
+ startedAt: Date.now(),
94
+ lastHeartbeatAt: Date.now(),
95
+ endedAt: null,
96
+ status: "active",
97
+ costCents: 0,
98
+ });
99
+ const result = runWorktreeGc(".harmony-worktrees", store, {
100
+ minAgeMs: 60 * 60 * 1000,
101
+ });
102
+ expect(result.removed).toHaveLength(0);
103
+ expect(result.skipped).toContain(wtPath);
104
+ expect(cleanupWorktree).not.toHaveBeenCalled();
105
+ }
106
+ finally {
107
+ process.chdir(cwd);
108
+ }
109
+ });
110
+ it("keeps worktrees younger than minAgeMs even if unclaimed", () => {
111
+ const cwd = process.cwd();
112
+ process.chdir(repoDir);
113
+ try {
114
+ const wt = makeWorktreeDir("agent-fresh", 0);
115
+ const result = runWorktreeGc(".harmony-worktrees", store, {
116
+ minAgeMs: 60 * 60 * 1000,
117
+ });
118
+ expect(result.removed).toHaveLength(0);
119
+ expect(result.skipped).toContain(wt);
120
+ }
121
+ finally {
122
+ process.chdir(cwd);
123
+ }
124
+ });
125
+ it("returns cleanly when the worktrees directory doesn't exist yet", () => {
126
+ const cwd = process.cwd();
127
+ process.chdir(repoDir);
128
+ try {
129
+ const result = runWorktreeGc(".harmony-worktrees", store);
130
+ expect(result.checked).toBe(0);
131
+ expect(result.errors).toEqual([]);
132
+ }
133
+ finally {
134
+ process.chdir(cwd);
135
+ }
136
+ });
137
+ });
@@ -0,0 +1,45 @@
1
+ import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
2
+ import type { Card } from "@harmony/shared";
3
+ import type { StateStore } from "./state-store.js";
4
+ import type { AgentConfig } from "./types.js";
5
+ export type GuardDecision = {
6
+ allow: true;
7
+ } | {
8
+ allow: false;
9
+ reason: GuardReason;
10
+ detail: string;
11
+ };
12
+ export type GuardReason = "dlq" | "max_attempts" | "card_cost_cap" | "daily_budget";
13
+ /**
14
+ * BudgetGuard is consulted on every pickup and on every run start.
15
+ * It protects the daemon from three failure modes:
16
+ * 1. Cards that can never succeed (DLQ after N failed attempts).
17
+ * 2. Cards that burn unbounded tokens on a single attempt.
18
+ * 3. Runaway daily spend across the entire daemon.
19
+ *
20
+ * The guard is advisory for the hot path (returns a decision); the
21
+ * caller is responsible for marking DLQ and skipping the enqueue.
22
+ */
23
+ export declare class BudgetGuard {
24
+ private config;
25
+ private store;
26
+ constructor(config: AgentConfig["budget"], store: StateStore);
27
+ /**
28
+ * Inspect a card before we commit to picking it up. If any threshold
29
+ * is already exceeded, return a skip decision — the caller should
30
+ * apply the DLQ label (for `dlq`/`max_attempts`/`card_cost_cap`) or
31
+ * simply hold until the daily budget resets (`daily_budget`).
32
+ */
33
+ check(cardId: string): GuardDecision;
34
+ /**
35
+ * Does the guard's decision warrant a permanent DLQ marker? The daily
36
+ * budget is *not* permanent — it resets at UTC midnight — so we only
37
+ * DLQ for terminal states.
38
+ */
39
+ isTerminal(reason: GuardReason): boolean;
40
+ /**
41
+ * Apply the DLQ label to a card and post a comment explaining why.
42
+ * Safe to call repeatedly — labels are idempotent.
43
+ */
44
+ markDlq(client: HarmonyApiClient, card: Card, reason: GuardReason, detail: string): Promise<void>;
45
+ }
package/dist/budget.js ADDED
@@ -0,0 +1,94 @@
1
+ import { log } from "./log.js";
2
+ import { runTransition } from "./transitions.js";
3
+ const TAG = "budget";
4
+ /**
5
+ * BudgetGuard is consulted on every pickup and on every run start.
6
+ * It protects the daemon from three failure modes:
7
+ * 1. Cards that can never succeed (DLQ after N failed attempts).
8
+ * 2. Cards that burn unbounded tokens on a single attempt.
9
+ * 3. Runaway daily spend across the entire daemon.
10
+ *
11
+ * The guard is advisory for the hot path (returns a decision); the
12
+ * caller is responsible for marking DLQ and skipping the enqueue.
13
+ */
14
+ export class BudgetGuard {
15
+ config;
16
+ store;
17
+ constructor(config, store) {
18
+ this.config = config;
19
+ this.store = store;
20
+ }
21
+ /**
22
+ * Inspect a card before we commit to picking it up. If any threshold
23
+ * is already exceeded, return a skip decision — the caller should
24
+ * apply the DLQ label (for `dlq`/`max_attempts`/`card_cost_cap`) or
25
+ * simply hold until the daily budget resets (`daily_budget`).
26
+ */
27
+ check(cardId) {
28
+ if (this.store.isDlq(cardId)) {
29
+ const rec = this.store.getCard(cardId);
30
+ return {
31
+ allow: false,
32
+ reason: "dlq",
33
+ detail: rec?.dlqReason ?? "previously marked DLQ",
34
+ };
35
+ }
36
+ const card = this.store.getCard(cardId);
37
+ if (card) {
38
+ if (card.attempts >= this.config.maxAttemptsPerCard) {
39
+ return {
40
+ allow: false,
41
+ reason: "max_attempts",
42
+ detail: `${card.attempts} of ${this.config.maxAttemptsPerCard} attempts exhausted`,
43
+ };
44
+ }
45
+ if (card.totalCostCents >= this.config.maxCentsPerCard) {
46
+ return {
47
+ allow: false,
48
+ reason: "card_cost_cap",
49
+ detail: `spent ${formatCents(card.totalCostCents)} of ${formatCents(this.config.maxCentsPerCard)} per-card cap`,
50
+ };
51
+ }
52
+ }
53
+ const dailySpent = this.store.getDailyCostCents();
54
+ if (dailySpent >= this.config.dailyBudgetCents) {
55
+ return {
56
+ allow: false,
57
+ reason: "daily_budget",
58
+ detail: `daily budget ${formatCents(dailySpent)}/${formatCents(this.config.dailyBudgetCents)} exhausted`,
59
+ };
60
+ }
61
+ return { allow: true };
62
+ }
63
+ /**
64
+ * Does the guard's decision warrant a permanent DLQ marker? The daily
65
+ * budget is *not* permanent — it resets at UTC midnight — so we only
66
+ * DLQ for terminal states.
67
+ */
68
+ isTerminal(reason) {
69
+ return (reason === "dlq" ||
70
+ reason === "max_attempts" ||
71
+ reason === "card_cost_cap");
72
+ }
73
+ /**
74
+ * Apply the DLQ label to a card and post a comment explaining why.
75
+ * Safe to call repeatedly — labels are idempotent.
76
+ */
77
+ async markDlq(client, card, reason, detail) {
78
+ await this.store.markDlq(card.id, `${reason}: ${detail}`);
79
+ try {
80
+ await runTransition(client, card, {
81
+ addLabels: [
82
+ { name: this.config.dlqLabel, color: this.config.dlqLabelColor },
83
+ ],
84
+ });
85
+ }
86
+ catch (err) {
87
+ log.warn(TAG, `failed to add dlq label to #${card.short_id}: ${err instanceof Error ? err.message : err}`);
88
+ }
89
+ log.warn(TAG, `#${card.short_id} DLQ'd — ${reason}: ${detail}`);
90
+ }
91
+ }
92
+ function formatCents(cents) {
93
+ return `$${(cents / 100).toFixed(2)}`;
94
+ }
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import "./index.js";
2
+ /**
3
+ * Harmony Agent CLI entry point.
4
+ *
5
+ * Subcommands:
6
+ * (no args) — run the daemon (same as `run`)
7
+ * run — run the daemon
8
+ * status — GET /status from the running daemon, pretty-print
9
+ * health — GET /health, exit 0 if healthy, 1 otherwise
10
+ * doctor — run preflight checks without starting the daemon
11
+ * gc — one-shot worktree garbage collection
12
+ * dlq list — print DLQ entries
13
+ * dlq clear <id> — clear a card's DLQ mark
14
+ * help — show usage
15
+ */
16
+ export {};
package/dist/cli.js CHANGED
@@ -1,2 +1,240 @@
1
1
  #!/usr/bin/env node
2
- import "./index.js";
2
+ /**
3
+ * Harmony Agent CLI entry point.
4
+ *
5
+ * Subcommands:
6
+ * (no args) — run the daemon (same as `run`)
7
+ * run — run the daemon
8
+ * status — GET /status from the running daemon, pretty-print
9
+ * health — GET /health, exit 0 if healthy, 1 otherwise
10
+ * doctor — run preflight checks without starting the daemon
11
+ * gc — one-shot worktree garbage collection
12
+ * dlq list — print DLQ entries
13
+ * dlq clear <id> — clear a card's DLQ mark
14
+ * help — show usage
15
+ */
16
+ import { log } from "./log.js";
17
+ const USAGE = `
18
+ Harmony Agent — push-based daemon + ops toolkit.
19
+
20
+ Usage:
21
+ harmony-agent [run] Start the daemon (default)
22
+ harmony-agent status Fetch status from the running daemon
23
+ harmony-agent health Exit 0 if daemon is healthy, 1 otherwise
24
+ harmony-agent doctor Run preflight checks (don't start)
25
+ harmony-agent gc One-shot worktree garbage collection
26
+ harmony-agent dlq list List dead-lettered cards
27
+ harmony-agent dlq clear <cardId> Clear a card's DLQ marker
28
+ harmony-agent help Show this help
29
+
30
+ Flags:
31
+ --pretty Force colored human log output
32
+ (also: HARMONY_AGENT_PRETTY=1)
33
+ --json Force structured JSON log output
34
+ (also: HARMONY_AGENT_JSON=1)
35
+
36
+ By default logs are pretty when stderr is a TTY and JSON when piped.
37
+ `.trim();
38
+ async function runDaemon() {
39
+ const { main } = await import("./index.js");
40
+ await main();
41
+ }
42
+ async function httpCall(path, init) {
43
+ const { loadDaemonConfig } = await import("./config.js");
44
+ const cfg = loadDaemonConfig();
45
+ const url = `http://${cfg.agent.http.bindAddr}:${cfg.agent.http.port}${path}`;
46
+ return fetch(url, init);
47
+ }
48
+ /** True if the error looks like "daemon is not running" (ECONNREFUSED). */
49
+ function isDaemonDown(err) {
50
+ const code = err?.cause?.code;
51
+ const msg = err instanceof Error ? err.message : String(err);
52
+ return (code === "ECONNREFUSED" || /ECONNREFUSED|fetch failed|connect/i.test(msg));
53
+ }
54
+ function printStatus(body) {
55
+ const out = process.stdout;
56
+ const uptime = formatDuration(body.uptimeMs);
57
+ out.write(`daemon ${body.daemonId} (pid ${body.daemonPid}, up ${uptime})\n`);
58
+ out.write(`budget $${(body.budget.todayCents / 100).toFixed(2)} / $${(body.budget.dailyCapCents / 100).toFixed(2)} today\n`);
59
+ out.write(`workers (${body.workers.length})\n`);
60
+ for (const w of body.workers) {
61
+ const card = w.cardId ? ` card=${w.cardId}` : "";
62
+ const br = w.branchName ? ` branch=${w.branchName}` : "";
63
+ out.write(` #${w.id} ${w.pipeline.padEnd(9)} ${w.state}${card}${br}\n`);
64
+ }
65
+ out.write(`impl queue (${body.implQueue.length})\n`);
66
+ for (const q of body.implQueue) {
67
+ out.write(` #${q.shortId} priority=${q.priority}\n`);
68
+ }
69
+ out.write(`review queue (${body.reviewQueue.length})\n`);
70
+ for (const q of body.reviewQueue) {
71
+ out.write(` #${q.shortId} priority=${q.priority}\n`);
72
+ }
73
+ out.write(`dlq (${body.dlq.length})\n`);
74
+ for (const d of body.dlq) {
75
+ out.write(` ${d.cardId} attempts=${d.attempts} cost=$${(d.totalCostCents / 100).toFixed(2)} reason=${d.reason}\n`);
76
+ }
77
+ }
78
+ function formatDuration(ms) {
79
+ const s = Math.floor(ms / 1000);
80
+ const h = Math.floor(s / 3600);
81
+ const m = Math.floor((s % 3600) / 60);
82
+ const sec = s % 60;
83
+ if (h > 0)
84
+ return `${h}h${m}m`;
85
+ if (m > 0)
86
+ return `${m}m${sec}s`;
87
+ return `${sec}s`;
88
+ }
89
+ async function statusCommand() {
90
+ try {
91
+ const res = await httpCall("/status");
92
+ if (!res.ok) {
93
+ process.stdout.write(`daemon responded ${res.status} ${res.statusText}\n`);
94
+ return 1;
95
+ }
96
+ const body = (await res.json());
97
+ printStatus(body);
98
+ return 0;
99
+ }
100
+ catch (err) {
101
+ process.stderr.write(`could not reach daemon: ${err instanceof Error ? err.message : err}\n`);
102
+ return 1;
103
+ }
104
+ }
105
+ async function healthCommand() {
106
+ try {
107
+ const res = await httpCall("/health");
108
+ const body = (await res.json());
109
+ process.stdout.write(`${JSON.stringify(body, null, 2)}\n`);
110
+ return body.healthy ? 0 : 1;
111
+ }
112
+ catch (err) {
113
+ process.stderr.write(`could not reach daemon: ${err instanceof Error ? err.message : err}\n`);
114
+ return 1;
115
+ }
116
+ }
117
+ async function doctorCommand() {
118
+ const { loadDaemonConfig } = await import("./config.js");
119
+ const { validatePrerequisites } = await import("./index.js");
120
+ try {
121
+ const config = loadDaemonConfig();
122
+ const userId = await validatePrerequisites(config);
123
+ process.stdout.write(`all checks passed — agent user: ${userId}\n`);
124
+ return 0;
125
+ }
126
+ catch (err) {
127
+ process.stderr.write(`preflight failed: ${err instanceof Error ? err.message : err}\n`);
128
+ return 1;
129
+ }
130
+ }
131
+ async function gcCommand() {
132
+ const { loadDaemonConfig } = await import("./config.js");
133
+ const { runWorktreeGc } = await import("./worktree-gc.js");
134
+ const { StateStore } = await import("./state-store.js");
135
+ const config = loadDaemonConfig();
136
+ const store = StateStore.open();
137
+ // For a manual gc, minAge=0 — operator is choosing to sweep now.
138
+ const result = runWorktreeGc(config.agent.worktree.basePath, store, {
139
+ minAgeMs: 0,
140
+ });
141
+ process.stdout.write(`checked: ${result.checked}, removed: ${result.removed.length}, skipped: ${result.skipped.length}, errors: ${result.errors.length}\n`);
142
+ if (result.removed.length) {
143
+ for (const p of result.removed)
144
+ process.stdout.write(` removed ${p}\n`);
145
+ }
146
+ if (result.errors.length) {
147
+ for (const e of result.errors) {
148
+ process.stderr.write(` error ${e.path}: ${e.error}\n`);
149
+ }
150
+ return 1;
151
+ }
152
+ return 0;
153
+ }
154
+ async function dlqCommand(args) {
155
+ const sub = args[0];
156
+ const { StateStore } = await import("./state-store.js");
157
+ const store = StateStore.open();
158
+ if (!sub || sub === "list") {
159
+ const entries = store.listDlq();
160
+ if (entries.length === 0) {
161
+ process.stdout.write("DLQ is empty\n");
162
+ return 0;
163
+ }
164
+ for (const c of entries) {
165
+ process.stdout.write(`${c.cardId} attempts=${c.attempts} cost=$${(c.totalCostCents / 100).toFixed(2)} reason=${c.dlqReason ?? "(unknown)"}\n`);
166
+ }
167
+ return 0;
168
+ }
169
+ if (sub === "clear") {
170
+ const cardId = args[1];
171
+ if (!cardId) {
172
+ process.stderr.write("usage: harmony-agent dlq clear <cardId>\n");
173
+ return 2;
174
+ }
175
+ // Prefer the running daemon if present — direct file writes race
176
+ // the daemon's own in-memory state-store and silently lose data.
177
+ try {
178
+ const res = await httpCall(`/dlq/clear/${encodeURIComponent(cardId)}`, {
179
+ method: "POST",
180
+ });
181
+ if (res.ok) {
182
+ process.stdout.write(`cleared DLQ for ${cardId} (via daemon)\n`);
183
+ return 0;
184
+ }
185
+ process.stderr.write(`daemon returned ${res.status} ${res.statusText}\n`);
186
+ return 1;
187
+ }
188
+ catch (err) {
189
+ if (!isDaemonDown(err)) {
190
+ process.stderr.write(`daemon HTTP error: ${err instanceof Error ? err.message : err}\n`);
191
+ return 1;
192
+ }
193
+ // Daemon offline → safe to write directly.
194
+ await store.clearDlq(cardId);
195
+ process.stdout.write(`cleared DLQ for ${cardId} (daemon offline, wrote directly)\n`);
196
+ return 0;
197
+ }
198
+ }
199
+ process.stderr.write(`unknown dlq subcommand: ${sub}\n`);
200
+ return 2;
201
+ }
202
+ async function dispatch(argv) {
203
+ // Strip node, script, and any global flags we own.
204
+ const args = argv.filter((a) => a !== "--pretty" && a !== "--json");
205
+ const cmd = args[0];
206
+ switch (cmd) {
207
+ case undefined:
208
+ case "run":
209
+ case "daemon":
210
+ await runDaemon();
211
+ return 0;
212
+ case "status":
213
+ return statusCommand();
214
+ case "health":
215
+ return healthCommand();
216
+ case "doctor":
217
+ return doctorCommand();
218
+ case "gc":
219
+ return gcCommand();
220
+ case "dlq":
221
+ return dlqCommand(args.slice(1));
222
+ case "help":
223
+ case "--help":
224
+ case "-h":
225
+ process.stdout.write(`${USAGE}\n`);
226
+ return 0;
227
+ default:
228
+ process.stderr.write(`unknown subcommand: ${cmd}\n${USAGE}\n`);
229
+ return 2;
230
+ }
231
+ }
232
+ dispatch(process.argv.slice(2))
233
+ .then((code) => {
234
+ if (code !== 0)
235
+ process.exit(code);
236
+ })
237
+ .catch((err) => {
238
+ log.error("cli", err instanceof Error ? err.message : String(err));
239
+ process.exit(1);
240
+ });
@@ -8,6 +8,15 @@ export interface SessionStats {
8
8
  toolCalls: number;
9
9
  cost: CostUpdate | null;
10
10
  }
11
+ export declare function buildTokenPayload(stats?: SessionStats | null): {
12
+ costCents?: undefined;
13
+ inputTokens?: undefined;
14
+ outputTokens?: undefined;
15
+ } | {
16
+ costCents: number;
17
+ inputTokens: number;
18
+ outputTokens: number;
19
+ };
11
20
  /**
12
21
  * Post-work pipeline: push branch, create PR, move card, post summary.
13
22
  */
@@ -6,6 +6,22 @@ import { AGENT_NAME, agentIdentifier } from "./types.js";
6
6
  import { attemptAutoFix, reportFindings, runVerification, } from "./verification.js";
7
7
  import { cleanupWorktree } from "./worktree.js";
8
8
  const TAG = "completion";
9
+ function formatTokenCount(tokens) {
10
+ if (tokens >= 1_000_000)
11
+ return `${(tokens / 1_000_000).toFixed(1)}M`;
12
+ if (tokens >= 1_000)
13
+ return `${(tokens / 1_000).toFixed(1)}k`;
14
+ return String(tokens);
15
+ }
16
+ export function buildTokenPayload(stats) {
17
+ if (!stats?.cost)
18
+ return {};
19
+ return {
20
+ costCents: Math.round(stats.cost.totalCostUsd * 100),
21
+ inputTokens: stats.cost.totalInputTokens,
22
+ outputTokens: stats.cost.totalOutputTokens,
23
+ };
24
+ }
9
25
  /**
10
26
  * Post-work pipeline: push branch, create PR, move card, post summary.
11
27
  */
@@ -17,6 +33,7 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
17
33
  await client.endAgentSession(card.id, {
18
34
  status: "completed",
19
35
  progressPercent: 100,
36
+ ...buildTokenPayload(sessionStats),
20
37
  });
21
38
  cleanupWorktree(worktreePath, branchName);
22
39
  return;
@@ -54,7 +71,10 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
54
71
  log.warn(TAG, `Verification failed for #${card.short_id} — reporting findings`);
55
72
  await reportFindings(client, card.id, result);
56
73
  await moveCardToColumn(client, card, config.verification.failColumn);
57
- await client.endAgentSession(card.id, { status: "paused" });
74
+ await client.endAgentSession(card.id, {
75
+ status: "paused",
76
+ ...buildTokenPayload(sessionStats),
77
+ });
58
78
  cleanupWorktree(worktreePath, branchName);
59
79
  return;
60
80
  }
@@ -77,10 +97,11 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
77
97
  if (config.completion.postSummary) {
78
98
  await postSummary(client, card, branchName, worktreePath, prUrl, config.worktree.baseBranch, sessionStats);
79
99
  }
80
- // 6. End agent session
100
+ // 6. End agent session (include final token/cost snapshot)
81
101
  await client.endAgentSession(card.id, {
82
102
  status: "completed",
83
103
  progressPercent: 100,
104
+ ...buildTokenPayload(sessionStats),
84
105
  });
85
106
  // 7. Cleanup worktree
86
107
  cleanupWorktree(worktreePath, branchName);
@@ -119,6 +140,11 @@ async function postSummary(client, card, branchName, worktreePath, prUrl, baseBr
119
140
  if (sessionStats.cost) {
120
141
  statParts.push(`$${sessionStats.cost.totalCostUsd.toFixed(2)} cost`);
121
142
  statParts.push(`${sessionStats.cost.numTurns} turns`);
143
+ const totalTokens = sessionStats.cost.totalInputTokens +
144
+ sessionStats.cost.totalOutputTokens;
145
+ if (totalTokens > 0) {
146
+ statParts.push(`${formatTokenCount(totalTokens)} tokens`);
147
+ }
122
148
  }
123
149
  parts.push(`Stats: ${statParts.join(" · ")}`);
124
150
  }
@@ -0,0 +1,18 @@
1
+ import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
2
+ import type { AgentConfig } from "./types.js";
3
+ export declare class ConfigValidationError extends Error {
4
+ readonly issues: string[];
5
+ constructor(message: string, issues: string[]);
6
+ }
7
+ /**
8
+ * Validate that every column referenced in the agent config actually
9
+ * exists on the board. Labels that the daemon needs to create (agent,
10
+ * Ready to Merge, Merged, Need Review, agent-recovered) are not
11
+ * required to pre-exist — the daemon creates them on demand. Columns
12
+ * must already exist because moving a card to a non-existent column
13
+ * silently fails and leaves cards stuck.
14
+ *
15
+ * Fail-fast on any mismatch: the operator fixes their config OR the
16
+ * board before the daemon is useful.
17
+ */
18
+ export declare function validateColumnReferences(client: HarmonyApiClient, projectId: string, config: AgentConfig): Promise<void>;