@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.
- package/README.md +67 -16
- package/dist/__tests__/budget.test.d.ts +1 -0
- package/dist/__tests__/budget.test.js +94 -0
- package/dist/__tests__/config-validation.test.d.ts +1 -0
- package/dist/__tests__/config-validation.test.js +65 -0
- package/dist/__tests__/dev-server-readiness.test.d.ts +1 -0
- package/dist/__tests__/dev-server-readiness.test.js +26 -0
- package/dist/__tests__/http-server.test.d.ts +1 -0
- package/dist/__tests__/http-server.test.js +115 -0
- package/dist/__tests__/log.test.d.ts +1 -0
- package/dist/__tests__/log.test.js +115 -0
- package/dist/__tests__/process-group.test.d.ts +1 -0
- package/dist/__tests__/process-group.test.js +68 -0
- package/dist/__tests__/reconcile-heartbeat.test.d.ts +1 -0
- package/dist/__tests__/reconcile-heartbeat.test.js +116 -0
- package/dist/__tests__/recovery.test.d.ts +1 -0
- package/dist/__tests__/recovery.test.js +126 -0
- package/dist/__tests__/review-parser.test.d.ts +1 -0
- package/dist/__tests__/review-parser.test.js +65 -0
- package/dist/__tests__/state-store.test.d.ts +1 -0
- package/dist/__tests__/state-store.test.js +132 -0
- package/dist/__tests__/transitions.test.d.ts +1 -0
- package/dist/__tests__/transitions.test.js +130 -0
- package/dist/__tests__/worktree-gc.test.d.ts +1 -0
- package/dist/__tests__/worktree-gc.test.js +137 -0
- package/dist/budget.d.ts +45 -0
- package/dist/budget.js +94 -0
- package/dist/cli.d.ts +15 -1
- package/dist/cli.js +239 -1
- package/dist/completion.d.ts +9 -0
- package/dist/completion.js +28 -2
- package/dist/config-validation.d.ts +18 -0
- package/dist/config-validation.js +66 -0
- package/dist/config.js +12 -0
- package/dist/http-server.d.ts +79 -0
- package/dist/http-server.js +115 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +125 -10
- package/dist/log.d.ts +29 -5
- package/dist/log.js +80 -15
- package/dist/pool.d.ts +27 -2
- package/dist/pool.js +69 -4
- package/dist/process-group.d.ts +26 -0
- package/dist/process-group.js +72 -0
- package/dist/progress-tracker.js +2 -0
- package/dist/queue.d.ts +2 -0
- package/dist/queue.js +4 -0
- package/dist/reconcile.d.ts +15 -1
- package/dist/reconcile.js +63 -2
- package/dist/recovery.d.ts +30 -0
- package/dist/recovery.js +136 -0
- package/dist/review-completion.d.ts +12 -4
- package/dist/review-completion.js +158 -49
- package/dist/review-worker.d.ts +9 -2
- package/dist/review-worker.js +182 -78
- package/dist/run-log.d.ts +6 -0
- package/dist/run-log.js +19 -0
- package/dist/state-store.d.ts +72 -0
- package/dist/state-store.js +216 -0
- package/dist/transitions.d.ts +57 -0
- package/dist/transitions.js +131 -0
- package/dist/types.d.ts +23 -0
- package/dist/types.js +19 -1
- package/dist/verification.d.ts +17 -0
- package/dist/verification.js +71 -10
- package/dist/watcher.d.ts +2 -0
- package/dist/watcher.js +11 -0
- package/dist/worker.d.ts +9 -2
- package/dist/worker.js +168 -47
- package/dist/worktree-gc.d.ts +39 -0
- package/dist/worktree-gc.js +139 -0
- 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
|
-
*
|
|
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
|
-
//
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
scopeCheck,
|
|
52
|
-
findings,
|
|
53
|
-
};
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// try next block
|
|
76
|
+
}
|
|
54
77
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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:
|
|
60
|
-
summary:
|
|
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, {
|
|
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, {
|
|
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)
|
package/dist/review-worker.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|