@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
@@ -1,66 +1,132 @@
1
1
  import { addLabelByName, moveCardToColumn } from "./board-helpers.js";
2
+ import { buildTokenPayload } from "./completion.js";
2
3
  import { createPullRequest, detectGitProvider, pushBranch } from "./git-pr.js";
3
4
  import { log } from "./log.js";
5
+ import { NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR, } from "./types.js";
4
6
  import { cleanupWorktree } from "./worktree.js";
5
7
  const TAG = "review-completion";
6
8
  const MAX_FINDINGS = 10;
7
9
  const REVIEW_MARKER = "---\n**Review:";
10
+ /**
11
+ * Extract structured fields from a parsed JSON object into a ReviewResult.
12
+ */
13
+ function extractResult(parsed) {
14
+ const verdict = parsed.verdict === "approved" || parsed.verdict === "rejected"
15
+ ? parsed.verdict
16
+ : "rejected";
17
+ const findings = Array.isArray(parsed.findings)
18
+ ? parsed.findings
19
+ .filter((f) => typeof f === "object" && f !== null && "title" in f)
20
+ .map((f) => ({
21
+ severity: f.severity === "critical"
22
+ ? "critical"
23
+ : f.severity === "minor"
24
+ ? "minor"
25
+ : "major",
26
+ title: String(f.title ?? "Untitled finding"),
27
+ description: String(f.description ?? ""),
28
+ category: f.category ? String(f.category) : undefined,
29
+ location: f.location ? String(f.location) : undefined,
30
+ }))
31
+ : [];
32
+ const scopeCheck = parsed.scopeCheck &&
33
+ typeof parsed.scopeCheck === "object" &&
34
+ "status" in parsed.scopeCheck
35
+ ? {
36
+ status: ["clean", "drift", "missing"].includes(parsed.scopeCheck.status)
37
+ ? parsed.scopeCheck.status
38
+ : "clean",
39
+ notes: parsed.scopeCheck.notes
40
+ ? String(parsed.scopeCheck.notes)
41
+ : undefined,
42
+ }
43
+ : undefined;
44
+ return {
45
+ verdict,
46
+ summary: String(parsed.summary ?? "").slice(0, 2000),
47
+ scopeCheck,
48
+ findings,
49
+ };
50
+ }
8
51
  /**
9
52
  * Parse Claude's review output into a structured ReviewResult.
10
- * Looks for a JSON block in the output.
53
+ *
54
+ * Tries multiple extraction strategies in order:
55
+ * 1. ```json ... ``` fenced block (what the prompt asks for)
56
+ * 2. Any top-level JSON object containing a "verdict" key (last-wins)
57
+ * 3. Regex for a bare `"verdict": "approved|rejected"` anywhere — lossy
58
+ * but keeps the pipeline moving
59
+ * 4. Falls back to verdict: "error" — keeps card in Review instead of
60
+ * bouncing it to To Do for a parse failure that isn't a code quality signal.
11
61
  */
12
62
  export function parseReviewOutput(stdout) {
13
- // Try to find a JSON block in the output
14
- const jsonMatch = stdout.match(/```json\s*([\s\S]*?)```/);
15
- const rawJson = jsonMatch?.[1]?.trim() ?? stdout.trim();
16
- try {
17
- const parsed = JSON.parse(rawJson);
18
- const verdict = parsed.verdict === "approved" || parsed.verdict === "rejected"
19
- ? parsed.verdict
20
- : "rejected";
21
- const findings = Array.isArray(parsed.findings)
22
- ? parsed.findings
23
- .filter((f) => typeof f === "object" && f !== null && "title" in f)
24
- .map((f) => ({
25
- severity: f.severity === "critical"
26
- ? "critical"
27
- : f.severity === "minor"
28
- ? "minor"
29
- : "major",
30
- title: String(f.title ?? "Untitled finding"),
31
- description: String(f.description ?? ""),
32
- category: f.category ? String(f.category) : undefined,
33
- location: f.location ? String(f.location) : undefined,
34
- }))
35
- : [];
36
- const scopeCheck = parsed.scopeCheck &&
37
- typeof parsed.scopeCheck === "object" &&
38
- "status" in parsed.scopeCheck
39
- ? {
40
- status: ["clean", "drift", "missing"].includes(parsed.scopeCheck.status)
41
- ? parsed.scopeCheck.status
42
- : "clean",
43
- notes: parsed.scopeCheck.notes
44
- ? String(parsed.scopeCheck.notes)
45
- : undefined,
63
+ // Strategy 1: fenced ```json block (greedy-last to handle multiple blocks)
64
+ const fencedBlocks = [...stdout.matchAll(/```json\s*([\s\S]*?)```/g)];
65
+ for (let i = fencedBlocks.length - 1; i >= 0; i--) {
66
+ const raw = fencedBlocks[i][1].trim();
67
+ try {
68
+ const parsed = JSON.parse(raw);
69
+ if (parsed && typeof parsed === "object" && "verdict" in parsed) {
70
+ log.debug(TAG, "Parsed review output from fenced JSON block");
71
+ return extractResult(parsed);
46
72
  }
47
- : undefined;
48
- return {
49
- verdict,
50
- summary: String(parsed.summary ?? "").slice(0, 2000),
51
- scopeCheck,
52
- findings,
53
- };
73
+ }
74
+ catch {
75
+ // try next block
76
+ }
54
77
  }
55
- catch {
56
- // If we can't parse JSON, treat as rejection with the raw output as summary
57
- log.warn(TAG, "Failed to parse review JSON output, treating as rejection");
78
+ // Strategy 2: scan every top-level { ... } block and take the last one
79
+ // that parses AND contains "verdict". This handles cases where the output
80
+ // has multiple stray braces before the real JSON object.
81
+ const candidates = [];
82
+ let depth = 0;
83
+ let start = -1;
84
+ for (let i = 0; i < stdout.length; i++) {
85
+ const ch = stdout[i];
86
+ if (ch === "{") {
87
+ if (depth === 0)
88
+ start = i;
89
+ depth++;
90
+ }
91
+ else if (ch === "}") {
92
+ depth--;
93
+ if (depth === 0 && start !== -1) {
94
+ candidates.push(stdout.slice(start, i + 1));
95
+ start = -1;
96
+ }
97
+ }
98
+ }
99
+ for (let i = candidates.length - 1; i >= 0; i--) {
100
+ try {
101
+ const parsed = JSON.parse(candidates[i]);
102
+ if (parsed && typeof parsed === "object" && "verdict" in parsed) {
103
+ log.debug(TAG, "Parsed review output from raw JSON object");
104
+ return extractResult(parsed);
105
+ }
106
+ }
107
+ catch {
108
+ // try next
109
+ }
110
+ }
111
+ // Strategy 3: regex for a bare verdict declaration anywhere in the output.
112
+ // Loses findings/summary but preserves approve/reject signal so the pipeline
113
+ // can make progress instead of looping on "error".
114
+ const verdictMatch = stdout.match(/"verdict"\s*:\s*"(approved|rejected)"/i);
115
+ if (verdictMatch) {
116
+ log.warn(TAG, `Parsed verdict via regex fallback — findings lost (${verdictMatch[1]})`);
58
117
  return {
59
- verdict: "rejected",
60
- summary: stdout.slice(0, 500),
118
+ verdict: verdictMatch[1].toLowerCase(),
119
+ summary: "Parsed via regex fallback — original JSON was malformed. Check run log.",
61
120
  findings: [],
62
121
  };
63
122
  }
123
+ // Strategy 4: nothing parseable — return error verdict so the card stays in Review
124
+ log.warn(TAG, "Failed to parse review JSON output — returning error verdict (card stays in Review)");
125
+ return {
126
+ verdict: "error",
127
+ summary: stdout.slice(0, 500),
128
+ findings: [],
129
+ };
64
130
  }
65
131
  /**
66
132
  * Get the current review cycle count from card description.
@@ -97,7 +163,7 @@ function stripReviewSummary(description) {
97
163
  * Handles approved/rejected verdicts, creates subtasks for findings,
98
164
  * and moves the card to the appropriate column.
99
165
  */
100
- export async function runReviewCompletion(client, card, result, config, worktreePath, branchName) {
166
+ export async function runReviewCompletion(client, card, result, config, worktreePath, branchName, sessionStats) {
101
167
  // Re-fetch card for fresh description (avoids stale data from enqueue time)
102
168
  let freshDesc;
103
169
  try {
@@ -109,6 +175,42 @@ export async function runReviewCompletion(client, card, result, config, worktree
109
175
  }
110
176
  const currentCycle = getReviewCycle(freshDesc) + 1;
111
177
  const maxCycles = config.review.maxReviewCycles;
178
+ if (result.verdict === "error") {
179
+ // Parse failure — not a code quality signal. Keep card in Review and
180
+ // add the "Need Review" label so reconcile stops re-enqueueing it.
181
+ // Without the label, the reconcile loop would respawn the review every
182
+ // cycle and burn budget on the same unparseable output (see #122).
183
+ log.warn(TAG, `#${card.short_id} review output unparseable — labelling "${NEED_REVIEW_LABEL}" for manual inspection`);
184
+ try {
185
+ await addLabelByName(client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
186
+ }
187
+ catch (err) {
188
+ log.warn(TAG, `Failed to add "${NEED_REVIEW_LABEL}" label: ${err instanceof Error ? err.message : err}`);
189
+ }
190
+ if (config.review.postFindings) {
191
+ const baseDesc = stripReviewSummary(freshDesc);
192
+ const summary = [
193
+ `\n\n${REVIEW_MARKER} Parse error**`,
194
+ '\nThe review agent\'s output could not be parsed. Card stays in Review with the "Need Review" label — check the run log in ~/.harmony-mcp/runs/ for diagnosis.',
195
+ result.summary ? `\n\nRaw output (truncated):\n${result.summary}` : "",
196
+ ].join("");
197
+ try {
198
+ await client.updateCard(card.id, { description: baseDesc + summary });
199
+ }
200
+ catch (err) {
201
+ log.error(TAG, `Failed to update description: ${err instanceof Error ? err.message : err}`);
202
+ }
203
+ }
204
+ await client.endAgentSession(card.id, {
205
+ status: "paused",
206
+ ...buildTokenPayload(sessionStats),
207
+ });
208
+ // Cleanup worktree but do NOT move the card
209
+ if (branchName) {
210
+ cleanupWorktree(worktreePath, branchName);
211
+ }
212
+ return;
213
+ }
112
214
  if (result.verdict === "approved") {
113
215
  // Ensure branch is pushed (skip in local mode — no branch to push)
114
216
  let prUrl = null;
@@ -150,6 +252,7 @@ export async function runReviewCompletion(client, card, result, config, worktree
150
252
  await client.endAgentSession(card.id, {
151
253
  status: "completed",
152
254
  progressPercent: 100,
255
+ ...buildTokenPayload(sessionStats),
153
256
  });
154
257
  log.info(TAG, `#${card.short_id} approved${prUrl ? ` — PR: ${prUrl}` : ""} — labeled "${config.review.approvedLabel}"`);
155
258
  }
@@ -181,7 +284,10 @@ export async function runReviewCompletion(client, card, result, config, worktree
181
284
  catch (err) {
182
285
  log.error(TAG, `Failed to update description: ${err instanceof Error ? err.message : err}`);
183
286
  }
184
- await client.endAgentSession(card.id, { status: "completed" });
287
+ await client.endAgentSession(card.id, {
288
+ status: "completed",
289
+ ...buildTokenPayload(sessionStats),
290
+ });
185
291
  if (branchName) {
186
292
  cleanupWorktree(worktreePath, branchName);
187
293
  }
@@ -243,7 +349,10 @@ export async function runReviewCompletion(client, card, result, config, worktree
243
349
  }
244
350
  // Move back to failColumn (To Do) for re-implementation
245
351
  await moveCardToColumn(client, card, config.review.failColumn);
246
- await client.endAgentSession(card.id, { status: "paused" });
352
+ await client.endAgentSession(card.id, {
353
+ status: "paused",
354
+ ...buildTokenPayload(sessionStats),
355
+ });
247
356
  log.info(TAG, `#${card.short_id} rejected (cycle ${currentCycle}/${maxCycles}) — moved to "${config.review.failColumn}"`);
248
357
  }
249
358
  // Cleanup worktree (skip in local mode — no worktree to clean)
@@ -1,10 +1,12 @@
1
1
  import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
2
2
  import type { Card, Column, Label, Subtask } from "@harmony/shared";
3
+ import { type StateStore } from "./state-store.js";
3
4
  import { type AgentConfig, type WorkerState } from "./types.js";
4
5
  export declare class ReviewWorker {
5
6
  private config;
6
7
  private client;
7
8
  private onDone;
9
+ private stateStore;
8
10
  id: number;
9
11
  state: WorkerState;
10
12
  cardId: string | null;
@@ -14,9 +16,15 @@ export declare class ReviewWorker {
14
16
  private process;
15
17
  private devServerProcess;
16
18
  private timeoutTimer;
19
+ private heartbeatTimer;
17
20
  private progressTracker;
21
+ private lastSessionStats;
18
22
  private aborted;
19
- constructor(id: number, config: AgentConfig, client: HarmonyApiClient, _userEmail: string, onDone: (worker: ReviewWorker) => void);
23
+ private runId;
24
+ constructor(id: number, config: AgentConfig, client: HarmonyApiClient, _userEmail: string, onDone: (worker: ReviewWorker) => void, stateStore: StateStore);
25
+ private startHeartbeat;
26
+ private stopHeartbeat;
27
+ private recordPhase;
20
28
  get tag(): string;
21
29
  get isIdle(): boolean;
22
30
  private get reviewPort();
@@ -39,7 +47,6 @@ export declare class ReviewWorker {
39
47
  */
40
48
  cancel(): Promise<void>;
41
49
  private spawnClaude;
42
- private waitForExit;
43
50
  private killDevServer;
44
51
  private resolveLocalChanges;
45
52
  private cleanup;