@gethmy/agent 1.7.0 → 1.7.2
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 +8 -1
- package/dist/cli.js +6376 -205
- package/dist/index.js +6206 -341
- package/package.json +2 -2
- package/dist/board-helpers.d.ts +0 -31
- package/dist/board-helpers.js +0 -150
- package/dist/budget.d.ts +0 -47
- package/dist/budget.js +0 -161
- package/dist/cli.d.ts +0 -16
- package/dist/completion.d.ts +0 -32
- package/dist/completion.js +0 -304
- package/dist/config-validation.d.ts +0 -23
- package/dist/config-validation.js +0 -77
- package/dist/config.d.ts +0 -23
- package/dist/config.js +0 -103
- package/dist/episode-writer.d.ts +0 -84
- package/dist/episode-writer.js +0 -232
- package/dist/git-pr.d.ts +0 -38
- package/dist/git-pr.js +0 -399
- package/dist/http-server.d.ts +0 -79
- package/dist/http-server.js +0 -114
- package/dist/index.d.ts +0 -5
- package/dist/log.d.ts +0 -34
- package/dist/log.js +0 -100
- package/dist/merge-monitor.d.ts +0 -23
- package/dist/merge-monitor.js +0 -169
- package/dist/pm.d.ts +0 -14
- package/dist/pm.js +0 -63
- package/dist/pool.d.ts +0 -70
- package/dist/pool.js +0 -258
- package/dist/process-group.d.ts +0 -26
- package/dist/process-group.js +0 -72
- package/dist/progress-tracker.d.ts +0 -79
- package/dist/progress-tracker.js +0 -442
- package/dist/prompt.d.ts +0 -18
- package/dist/prompt.js +0 -117
- package/dist/queue.d.ts +0 -39
- package/dist/queue.js +0 -100
- package/dist/reconcile.d.ts +0 -35
- package/dist/reconcile.js +0 -174
- package/dist/recovery.d.ts +0 -30
- package/dist/recovery.js +0 -141
- package/dist/review-completion.d.ts +0 -40
- package/dist/review-completion.js +0 -474
- package/dist/review-knowledge.d.ts +0 -14
- package/dist/review-knowledge.js +0 -89
- package/dist/review-prompt.d.ts +0 -12
- package/dist/review-prompt.js +0 -103
- package/dist/review-worker.d.ts +0 -56
- package/dist/review-worker.js +0 -638
- package/dist/review-worktree.d.ts +0 -12
- package/dist/review-worktree.js +0 -95
- package/dist/run-log.d.ts +0 -6
- package/dist/run-log.js +0 -19
- package/dist/startup-banner.d.ts +0 -29
- package/dist/startup-banner.js +0 -143
- package/dist/state-store.d.ts +0 -88
- package/dist/state-store.js +0 -239
- package/dist/stream-parser-selftest.d.ts +0 -9
- package/dist/stream-parser-selftest.js +0 -97
- package/dist/stream-parser.d.ts +0 -43
- package/dist/stream-parser.js +0 -174
- package/dist/transitions.d.ts +0 -57
- package/dist/transitions.js +0 -131
- package/dist/types.d.ts +0 -140
- package/dist/types.js +0 -79
- package/dist/verification.d.ts +0 -39
- package/dist/verification.js +0 -317
- package/dist/watcher.d.ts +0 -53
- package/dist/watcher.js +0 -153
- package/dist/worker.d.ts +0 -53
- package/dist/worker.js +0 -464
- package/dist/worktree-gc.d.ts +0 -67
- package/dist/worktree-gc.js +0 -245
- package/dist/worktree.d.ts +0 -18
- package/dist/worktree.js +0 -177
package/dist/episode-writer.js
DELETED
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
import { log } from "./log.js";
|
|
2
|
-
const TAG = "episode-writer";
|
|
3
|
-
const MAX_APPROACH_SUMMARY_CHARS = 400;
|
|
4
|
-
/**
|
|
5
|
-
* Rule-derived quality score (0..1) for an implement run. Failures default to 0.
|
|
6
|
-
* Plan §"Quality score": +0.4 if build passed, +0.2 if lint passed, +0.2 if no
|
|
7
|
-
* error thrown, +0.2 if run completed cleanly.
|
|
8
|
-
*/
|
|
9
|
-
export function computeQualityScore(result, opts) {
|
|
10
|
-
if (!result.passed)
|
|
11
|
-
return 0;
|
|
12
|
-
let score = 0;
|
|
13
|
-
if (result.buildErrors.length === 0)
|
|
14
|
-
score += 0.4;
|
|
15
|
-
if (result.lintWarnings.length === 0)
|
|
16
|
-
score += 0.2;
|
|
17
|
-
if (!opts.errorThrown)
|
|
18
|
-
score += 0.2;
|
|
19
|
-
if (opts.runCompletedCleanly)
|
|
20
|
-
score += 0.2;
|
|
21
|
-
return Math.min(1, score);
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Clamp confidence into the documented [0.4, 1.0] band so failures retain a
|
|
25
|
-
* minimum floor (plan §"Episode record shape").
|
|
26
|
-
*/
|
|
27
|
-
function clampConfidence(qualityScore) {
|
|
28
|
-
return Math.max(0.4, Math.min(1.0, qualityScore));
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Trim a free-form summary down to the documented 400-char cap. v1 uses a
|
|
32
|
-
* last-turn trim rather than an LLM rewrite (plan §"Write hook"). Empty or
|
|
33
|
-
* whitespace-only input collapses to a marker so the episode still surfaces
|
|
34
|
-
* as a recallable hit (rather than an empty bullet) in future prompts.
|
|
35
|
-
*/
|
|
36
|
-
export function trimApproachSummary(text) {
|
|
37
|
-
const trimmed = text.trim();
|
|
38
|
-
if (trimmed.length === 0)
|
|
39
|
-
return "(no approach summary captured)";
|
|
40
|
-
if (trimmed.length <= MAX_APPROACH_SUMMARY_CHARS)
|
|
41
|
-
return trimmed;
|
|
42
|
-
return `${trimmed.slice(0, MAX_APPROACH_SUMMARY_CHARS - 1).trimEnd()}…`;
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Build the entity payload for one episode. Pure — returned object can be
|
|
46
|
-
* snapshotted in tests without hitting the network.
|
|
47
|
-
*/
|
|
48
|
-
export function buildEpisodePayload(input, projectId) {
|
|
49
|
-
if (input.kind === "implement") {
|
|
50
|
-
const qualityScore = computeQualityScore(input.result, {
|
|
51
|
-
errorThrown: input.errorMessage !== undefined,
|
|
52
|
-
runCompletedCleanly: input.result.passed,
|
|
53
|
-
});
|
|
54
|
-
const type = input.outcome === "success" ? "solution" : "error";
|
|
55
|
-
const importance = input.outcome === "success" ? 7 : 5;
|
|
56
|
-
const approachSummary = trimApproachSummary(input.approachSummary);
|
|
57
|
-
const outcomeRationale = input.outcome === "success"
|
|
58
|
-
? `Build ${input.result.buildErrors.length === 0 ? "passed" : "failed"}, lint ${input.result.lintWarnings.length === 0 ? "clean" : "issues"}.`
|
|
59
|
-
: `Verification failed: ${input.errorMessage ?? "see findings"}.`;
|
|
60
|
-
const metadata = {
|
|
61
|
-
episode_kind: "implement",
|
|
62
|
-
card_short_id: input.card.short_id,
|
|
63
|
-
card_title: input.card.title,
|
|
64
|
-
approach_summary: approachSummary,
|
|
65
|
-
outcome: input.outcome,
|
|
66
|
-
quality_score: qualityScore,
|
|
67
|
-
duration_ms: input.cost?.durationMs ?? 0,
|
|
68
|
-
token_cost: {
|
|
69
|
-
input: input.cost?.totalInputTokens ?? 0,
|
|
70
|
-
output: input.cost?.totalOutputTokens ?? 0,
|
|
71
|
-
usd: input.cost?.totalCostUsd ?? 0,
|
|
72
|
-
},
|
|
73
|
-
files_touched: input.filesEdited,
|
|
74
|
-
num_turns: input.cost?.numTurns ?? 0,
|
|
75
|
-
};
|
|
76
|
-
if (input.errorMessage)
|
|
77
|
-
metadata.error = input.errorMessage;
|
|
78
|
-
if (input.agentSessionId)
|
|
79
|
-
metadata.agent_session_id = input.agentSessionId;
|
|
80
|
-
return {
|
|
81
|
-
workspace_id: input.workspaceId,
|
|
82
|
-
project_id: projectId,
|
|
83
|
-
type,
|
|
84
|
-
memory_tier: "episode",
|
|
85
|
-
scope: "project",
|
|
86
|
-
title: `Agent run implement — #${input.card.short_id}: ${input.card.title}`,
|
|
87
|
-
content: `${approachSummary}\n\nOutcome: ${outcomeRationale}`,
|
|
88
|
-
metadata,
|
|
89
|
-
importance,
|
|
90
|
-
confidence: clampConfidence(qualityScore),
|
|
91
|
-
tags: ["implement", input.outcome, `card:${input.card.short_id}`],
|
|
92
|
-
agent_identifier: "harmony-agent",
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
// Review episode
|
|
96
|
-
const qualityScore = input.verdict === "approved" ? 1 : 0.4;
|
|
97
|
-
const summary = trimApproachSummary(input.summary || "(no summary captured)");
|
|
98
|
-
const metadata = {
|
|
99
|
-
episode_kind: "review",
|
|
100
|
-
card_short_id: input.card.short_id,
|
|
101
|
-
card_title: input.card.title,
|
|
102
|
-
approach_summary: summary,
|
|
103
|
-
outcome: input.verdict === "approved" ? "success" : "failure",
|
|
104
|
-
quality_score: qualityScore,
|
|
105
|
-
duration_ms: input.cost?.durationMs ?? 0,
|
|
106
|
-
token_cost: {
|
|
107
|
-
input: input.cost?.totalInputTokens ?? 0,
|
|
108
|
-
output: input.cost?.totalOutputTokens ?? 0,
|
|
109
|
-
usd: input.cost?.totalCostUsd ?? 0,
|
|
110
|
-
},
|
|
111
|
-
files_touched: 0,
|
|
112
|
-
num_turns: input.cost?.numTurns ?? 0,
|
|
113
|
-
};
|
|
114
|
-
if (input.agentSessionId)
|
|
115
|
-
metadata.agent_session_id = input.agentSessionId;
|
|
116
|
-
if (input.reviewSessionId)
|
|
117
|
-
metadata.review_session_id = input.reviewSessionId;
|
|
118
|
-
if (input.originalEpisodeId)
|
|
119
|
-
metadata.original_episode_id = input.originalEpisodeId;
|
|
120
|
-
return {
|
|
121
|
-
workspace_id: input.workspaceId,
|
|
122
|
-
project_id: projectId,
|
|
123
|
-
type: "decision",
|
|
124
|
-
memory_tier: "episode",
|
|
125
|
-
scope: "project",
|
|
126
|
-
title: `Agent run review — #${input.card.short_id}: ${input.card.title}`,
|
|
127
|
-
content: `Review verdict: ${input.verdict}.\n\n${summary}`,
|
|
128
|
-
metadata,
|
|
129
|
-
importance: 8,
|
|
130
|
-
confidence: clampConfidence(qualityScore),
|
|
131
|
-
tags: ["review", input.verdict, `card:${input.card.short_id}`],
|
|
132
|
-
agent_identifier: "harmony-agent",
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* Write one episode entity. Best-effort: any failure is logged and swallowed
|
|
137
|
-
* so the calling pipeline can complete (plan D8: episode writes never block
|
|
138
|
-
* run completion).
|
|
139
|
-
*
|
|
140
|
-
* Returns the entity id on success, or null on swallowed failure.
|
|
141
|
-
*/
|
|
142
|
-
export async function writeEpisode(client, input) {
|
|
143
|
-
const payload = buildEpisodePayload(input, input.card.project_id);
|
|
144
|
-
try {
|
|
145
|
-
const { entity } = await client.createMemoryEntity({
|
|
146
|
-
...payload,
|
|
147
|
-
metadata: payload.metadata,
|
|
148
|
-
});
|
|
149
|
-
const id = entity && typeof entity === "object" && "id" in entity
|
|
150
|
-
? (entity.id ?? null)
|
|
151
|
-
: null;
|
|
152
|
-
log.info(TAG, `episode written for #${input.card.short_id}`, {
|
|
153
|
-
cardId: input.card.id,
|
|
154
|
-
event: "episode_write",
|
|
155
|
-
kind: input.kind,
|
|
156
|
-
});
|
|
157
|
-
return id;
|
|
158
|
-
}
|
|
159
|
-
catch (err) {
|
|
160
|
-
log.warn(TAG, `episode write failed for #${input.card.short_id}`, {
|
|
161
|
-
cardId: input.card.id,
|
|
162
|
-
event: "episode_write_failed",
|
|
163
|
-
kind: input.kind,
|
|
164
|
-
error: err instanceof Error ? err.message : String(err),
|
|
165
|
-
});
|
|
166
|
-
return null;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Find the most recent implement episode for a given card so the review
|
|
171
|
-
* pipeline can back-fill its verdict. Returns null when none exists or the
|
|
172
|
-
* lookup throws — back-fill is best-effort.
|
|
173
|
-
*/
|
|
174
|
-
export async function findLatestImplementEpisode(client, workspaceId, projectId, cardShortId) {
|
|
175
|
-
try {
|
|
176
|
-
const { entities } = await client.harmonyRecall({
|
|
177
|
-
workspaceId,
|
|
178
|
-
projectId,
|
|
179
|
-
type: ["solution", "error"],
|
|
180
|
-
memory_tier: "episode",
|
|
181
|
-
scope: "project",
|
|
182
|
-
tags: [`card:${cardShortId}`],
|
|
183
|
-
topK: 1,
|
|
184
|
-
});
|
|
185
|
-
const first = entities[0];
|
|
186
|
-
if (first &&
|
|
187
|
-
typeof first === "object" &&
|
|
188
|
-
"id" in first &&
|
|
189
|
-
typeof first.id === "string") {
|
|
190
|
-
return first.id;
|
|
191
|
-
}
|
|
192
|
-
return null;
|
|
193
|
-
}
|
|
194
|
-
catch (err) {
|
|
195
|
-
log.warn(TAG, "implement-episode lookup failed", {
|
|
196
|
-
event: "episode_lookup_failed",
|
|
197
|
-
cardShortId,
|
|
198
|
-
error: err instanceof Error ? err.message : String(err),
|
|
199
|
-
});
|
|
200
|
-
return null;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
/**
|
|
204
|
-
* Apply the review verdict to an earlier implement episode (plan §"Read hook"
|
|
205
|
-
* back-fill block). Approved nudges the original episode's confidence up;
|
|
206
|
-
* rejected tombstones it via superseded_by.
|
|
207
|
-
*/
|
|
208
|
-
export async function backfillReviewVerdict(client, originalEpisodeId, verdict, reviewEpisodeId) {
|
|
209
|
-
try {
|
|
210
|
-
if (verdict === "approved") {
|
|
211
|
-
const { entity } = await client.getMemoryEntity(originalEpisodeId);
|
|
212
|
-
const current = entity?.confidence ?? 0.4;
|
|
213
|
-
const bumped = Math.min(1, current + 0.05);
|
|
214
|
-
await client.updateMemoryEntity(originalEpisodeId, {
|
|
215
|
-
confidence: bumped,
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
else {
|
|
219
|
-
await client.updateMemoryEntity(originalEpisodeId, {
|
|
220
|
-
superseded_by: reviewEpisodeId,
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
catch (err) {
|
|
225
|
-
log.warn(TAG, "review back-fill failed", {
|
|
226
|
-
event: "episode_backfill_failed",
|
|
227
|
-
originalEpisodeId,
|
|
228
|
-
verdict,
|
|
229
|
-
error: err instanceof Error ? err.message : String(err),
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
}
|
package/dist/git-pr.d.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import type { Card } from "@harmony/shared";
|
|
2
|
-
import type { AgentConfig } from "./types.js";
|
|
3
|
-
export type GitProvider = "github" | "azure" | "gitlab" | "bitbucket" | "unknown";
|
|
4
|
-
export declare function detectGitProvider(cwd?: string): GitProvider;
|
|
5
|
-
/**
|
|
6
|
-
* Validate that the CLI for the detected git provider is installed and authenticated.
|
|
7
|
-
* Returns the provider name or throws with instructions.
|
|
8
|
-
*/
|
|
9
|
-
export declare function validateGitProviderCli(provider: GitProvider, cwd?: string): void;
|
|
10
|
-
export type PrState = "merged" | "open" | "closed" | "unknown";
|
|
11
|
-
/**
|
|
12
|
-
* Check whether a PR has been merged using the git provider CLI (async).
|
|
13
|
-
*/
|
|
14
|
-
export declare function checkPrMergeStatus(prUrl: string, cwd: string, provider: GitProvider): Promise<PrState>;
|
|
15
|
-
/**
|
|
16
|
-
* Extract a PR URL from card description. Matches the format written by completion.ts:
|
|
17
|
-
* `PR: https://...`
|
|
18
|
-
*/
|
|
19
|
-
export declare function extractPrUrl(description: string | null): string | null;
|
|
20
|
-
export declare function remoteBranchExists(branchName: string, cwd: string): boolean;
|
|
21
|
-
export declare function pushBranch(branchName: string, cwd: string): void;
|
|
22
|
-
/**
|
|
23
|
-
* Push the current branch's tip to `newRef` on origin and delete `oldRef`.
|
|
24
|
-
* Used when an approved attempt graduates from `agent-attempts/*` to
|
|
25
|
-
* `agent/*` — keeps the commits durable across the rename and avoids any
|
|
26
|
-
* window where the work is unreachable on origin.
|
|
27
|
-
*/
|
|
28
|
-
export declare function renameRemoteBranch(oldRef: string, newRef: string, cwd: string): void;
|
|
29
|
-
/**
|
|
30
|
-
* Best-effort public branch URL for the recovery button on a failed session.
|
|
31
|
-
* Returns null when we can't infer a tree URL — the daemon falls back to a
|
|
32
|
-
* plain `git fetch && git checkout <ref>` instruction in that case.
|
|
33
|
-
*/
|
|
34
|
-
export declare function getBranchWebUrl(branchName: string, cwd: string): string | null;
|
|
35
|
-
export declare function buildPrBody(card: Card, commitLog: string): string;
|
|
36
|
-
export declare function createPullRequest(card: Card, branchName: string, worktreePath: string, config: AgentConfig, provider: GitProvider): string | null;
|
|
37
|
-
export declare function findExistingPr(branchName: string, worktreePath: string, provider: GitProvider): string | null;
|
|
38
|
-
export declare function updateExistingPr(branchName: string, body: string, worktreePath: string, provider: GitProvider): void;
|
package/dist/git-pr.js
DELETED
|
@@ -1,399 +0,0 @@
|
|
|
1
|
-
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
-
import { promisify } from "node:util";
|
|
3
|
-
import { log } from "./log.js";
|
|
4
|
-
const execFileAsync = promisify(execFile);
|
|
5
|
-
const TAG = "git-pr";
|
|
6
|
-
export function detectGitProvider(cwd) {
|
|
7
|
-
try {
|
|
8
|
-
const url = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
9
|
-
cwd,
|
|
10
|
-
encoding: "utf-8",
|
|
11
|
-
}).trim();
|
|
12
|
-
if (url.includes("github.com"))
|
|
13
|
-
return "github";
|
|
14
|
-
if (url.includes("dev.azure.com") || url.includes("visualstudio.com"))
|
|
15
|
-
return "azure";
|
|
16
|
-
if (url.includes("gitlab.com") || /\bgitlab\b/.test(url))
|
|
17
|
-
return "gitlab";
|
|
18
|
-
if (url.includes("bitbucket.org"))
|
|
19
|
-
return "bitbucket";
|
|
20
|
-
return "unknown";
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
return "unknown";
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Validate that the CLI for the detected git provider is installed and authenticated.
|
|
28
|
-
* Returns the provider name or throws with instructions.
|
|
29
|
-
*/
|
|
30
|
-
export function validateGitProviderCli(provider, cwd) {
|
|
31
|
-
switch (provider) {
|
|
32
|
-
case "github": {
|
|
33
|
-
try {
|
|
34
|
-
execFileSync("gh", ["auth", "status"], { cwd, stdio: "pipe" });
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
throw new Error("GitHub CLI (gh) is not authenticated. Run: gh auth login");
|
|
38
|
-
}
|
|
39
|
-
break;
|
|
40
|
-
}
|
|
41
|
-
case "azure": {
|
|
42
|
-
try {
|
|
43
|
-
execFileSync("az", ["--version"], { cwd, stdio: "pipe" });
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
throw new Error("Azure CLI (az) not found. Install it: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli");
|
|
47
|
-
}
|
|
48
|
-
try {
|
|
49
|
-
execFileSync("az", ["account", "show"], { cwd, stdio: "pipe" });
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
throw new Error("Azure CLI is not authenticated. Run: az login");
|
|
53
|
-
}
|
|
54
|
-
break;
|
|
55
|
-
}
|
|
56
|
-
case "gitlab": {
|
|
57
|
-
try {
|
|
58
|
-
execFileSync("glab", ["auth", "status"], { cwd, stdio: "pipe" });
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
throw new Error("GitLab CLI (glab) is not installed or not authenticated. Install: https://gitlab.com/gitlab-org/cli — then run: glab auth login");
|
|
62
|
-
}
|
|
63
|
-
break;
|
|
64
|
-
}
|
|
65
|
-
case "bitbucket":
|
|
66
|
-
case "unknown":
|
|
67
|
-
log.warn(TAG, `Git provider "${provider}" — PR creation will be skipped (no CLI support)`);
|
|
68
|
-
break;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
const VALID_PR_URL_RE = /^https:\/\/(github\.com|gitlab\.com|dev\.azure\.com|bitbucket\.org)\//;
|
|
72
|
-
const PR_URL_RE = /PR:\s*(https?:\/\/[^\s)]+)/;
|
|
73
|
-
/** Validate that a PR URL looks like a real provider URL. */
|
|
74
|
-
function isValidPrUrl(url) {
|
|
75
|
-
return VALID_PR_URL_RE.test(url);
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Check whether a PR has been merged using the git provider CLI (async).
|
|
79
|
-
*/
|
|
80
|
-
export async function checkPrMergeStatus(prUrl, cwd, provider) {
|
|
81
|
-
if (!isValidPrUrl(prUrl))
|
|
82
|
-
return "unknown";
|
|
83
|
-
try {
|
|
84
|
-
switch (provider) {
|
|
85
|
-
case "github": {
|
|
86
|
-
const { stdout } = await execFileAsync("gh", ["pr", "view", prUrl, "--json", "state", "--jq", ".state"], { cwd, encoding: "utf-8", timeout: 10_000 });
|
|
87
|
-
switch (stdout.trim()) {
|
|
88
|
-
case "MERGED":
|
|
89
|
-
return "merged";
|
|
90
|
-
case "OPEN":
|
|
91
|
-
return "open";
|
|
92
|
-
case "CLOSED":
|
|
93
|
-
return "closed";
|
|
94
|
-
default:
|
|
95
|
-
return "unknown";
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
case "gitlab": {
|
|
99
|
-
// Extract MR ID from URL (e.g. .../merge_requests/42)
|
|
100
|
-
const mrMatch = prUrl.match(/merge_requests\/(\d+)/);
|
|
101
|
-
if (!mrMatch)
|
|
102
|
-
return "unknown";
|
|
103
|
-
const { stdout } = await execFileAsync("glab", ["mr", "view", mrMatch[1], "--output", "json"], { cwd, encoding: "utf-8", timeout: 10_000 });
|
|
104
|
-
let parsed;
|
|
105
|
-
try {
|
|
106
|
-
parsed = JSON.parse(stdout.trim());
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
log.warn(TAG, `Failed to parse glab JSON output for MR ${mrMatch[1]}`);
|
|
110
|
-
return "unknown";
|
|
111
|
-
}
|
|
112
|
-
if (typeof parsed !== "object" || parsed === null)
|
|
113
|
-
return "unknown";
|
|
114
|
-
const state = parsed.state;
|
|
115
|
-
if (state === "merged")
|
|
116
|
-
return "merged";
|
|
117
|
-
if (state === "opened")
|
|
118
|
-
return "open";
|
|
119
|
-
if (state === "closed")
|
|
120
|
-
return "closed";
|
|
121
|
-
return "unknown";
|
|
122
|
-
}
|
|
123
|
-
default:
|
|
124
|
-
return "unknown";
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
catch {
|
|
128
|
-
return "unknown";
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Extract a PR URL from card description. Matches the format written by completion.ts:
|
|
133
|
-
* `PR: https://...`
|
|
134
|
-
*/
|
|
135
|
-
export function extractPrUrl(description) {
|
|
136
|
-
if (!description)
|
|
137
|
-
return null;
|
|
138
|
-
const match = description.match(PR_URL_RE);
|
|
139
|
-
if (!match)
|
|
140
|
-
return null;
|
|
141
|
-
try {
|
|
142
|
-
return new URL(match[1]).href;
|
|
143
|
-
}
|
|
144
|
-
catch {
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
// ============ PUSH (REWORK-AWARE) ============
|
|
149
|
-
export function remoteBranchExists(branchName, cwd) {
|
|
150
|
-
try {
|
|
151
|
-
execFileSync("git", ["ls-remote", "--exit-code", "origin", `refs/heads/${branchName}`], { cwd, stdio: "pipe" });
|
|
152
|
-
return true;
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
155
|
-
return false;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
export function pushBranch(branchName, cwd) {
|
|
159
|
-
if (remoteBranchExists(branchName, cwd)) {
|
|
160
|
-
log.info(TAG, `Remote branch ${branchName} exists (rework), force-pushing`);
|
|
161
|
-
// Resolve the remote tip explicitly so the lease anchors to a known SHA.
|
|
162
|
-
// Bare `--force-with-lease` only checks against the local tracking ref,
|
|
163
|
-
// which can be stale if it was never fetched in this worktree — that
|
|
164
|
-
// lets a concurrent update slip through. Fetch + pin the expected SHA.
|
|
165
|
-
let expectedSha = null;
|
|
166
|
-
try {
|
|
167
|
-
execFileSync("git", ["fetch", "origin", branchName], {
|
|
168
|
-
cwd,
|
|
169
|
-
stdio: "pipe",
|
|
170
|
-
});
|
|
171
|
-
expectedSha = execFileSync("git", ["rev-parse", `refs/remotes/origin/${branchName}`], { cwd, encoding: "utf-8" }).trim();
|
|
172
|
-
}
|
|
173
|
-
catch (err) {
|
|
174
|
-
log.warn(TAG, `could not resolve remote tip for ${branchName}, falling back to weak lease: ${err instanceof Error ? err.message : err}`);
|
|
175
|
-
}
|
|
176
|
-
const lease = expectedSha
|
|
177
|
-
? `--force-with-lease=refs/heads/${branchName}:${expectedSha}`
|
|
178
|
-
: "--force-with-lease";
|
|
179
|
-
execFileSync("git", ["push", lease, "-u", "origin", branchName], {
|
|
180
|
-
cwd,
|
|
181
|
-
stdio: "pipe",
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
else {
|
|
185
|
-
execFileSync("git", ["push", "-u", "origin", branchName], {
|
|
186
|
-
cwd,
|
|
187
|
-
stdio: "pipe",
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* Push the current branch's tip to `newRef` on origin and delete `oldRef`.
|
|
193
|
-
* Used when an approved attempt graduates from `agent-attempts/*` to
|
|
194
|
-
* `agent/*` — keeps the commits durable across the rename and avoids any
|
|
195
|
-
* window where the work is unreachable on origin.
|
|
196
|
-
*/
|
|
197
|
-
export function renameRemoteBranch(oldRef, newRef, cwd) {
|
|
198
|
-
if (oldRef === newRef)
|
|
199
|
-
return;
|
|
200
|
-
let sha;
|
|
201
|
-
try {
|
|
202
|
-
sha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
203
|
-
cwd,
|
|
204
|
-
encoding: "utf-8",
|
|
205
|
-
}).trim();
|
|
206
|
-
}
|
|
207
|
-
catch (err) {
|
|
208
|
-
throw new Error(`renameRemoteBranch: could not resolve HEAD: ${err instanceof Error ? err.message : err}`);
|
|
209
|
-
}
|
|
210
|
-
log.info(TAG, `Renaming remote ${oldRef} → ${newRef}`);
|
|
211
|
-
execFileSync("git", ["push", "origin", `${sha}:refs/heads/${newRef}`, "--force-with-lease"], { cwd, stdio: "pipe" });
|
|
212
|
-
try {
|
|
213
|
-
execFileSync("git", ["push", "origin", `:refs/heads/${oldRef}`], {
|
|
214
|
-
cwd,
|
|
215
|
-
stdio: "pipe",
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
catch (err) {
|
|
219
|
-
log.warn(TAG, `renameRemoteBranch: could not delete old ref ${oldRef}: ${err instanceof Error ? err.message : err}`);
|
|
220
|
-
}
|
|
221
|
-
try {
|
|
222
|
-
execFileSync("git", ["branch", "-m", oldRef, newRef], {
|
|
223
|
-
cwd,
|
|
224
|
-
stdio: "pipe",
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
catch {
|
|
228
|
-
// Worktree may not have the old branch checked out — non-fatal.
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
/**
|
|
232
|
-
* Best-effort public branch URL for the recovery button on a failed session.
|
|
233
|
-
* Returns null when we can't infer a tree URL — the daemon falls back to a
|
|
234
|
-
* plain `git fetch && git checkout <ref>` instruction in that case.
|
|
235
|
-
*/
|
|
236
|
-
export function getBranchWebUrl(branchName, cwd) {
|
|
237
|
-
try {
|
|
238
|
-
const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
239
|
-
cwd,
|
|
240
|
-
encoding: "utf-8",
|
|
241
|
-
}).trim();
|
|
242
|
-
const encoded = branchName.split("/").map(encodeURIComponent).join("/");
|
|
243
|
-
if (/github\.com[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
|
|
244
|
-
const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
245
|
-
if (m)
|
|
246
|
-
return `https://github.com/${m[1]}/${m[2]}/tree/${encoded}`;
|
|
247
|
-
}
|
|
248
|
-
if (/gitlab\.com[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
|
|
249
|
-
const m = remoteUrl.match(/gitlab\.com[:/](.+?)(?:\.git)?$/);
|
|
250
|
-
if (m)
|
|
251
|
-
return `https://gitlab.com/${m[1]}/-/tree/${encoded}`;
|
|
252
|
-
}
|
|
253
|
-
if (/bitbucket\.org[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
|
|
254
|
-
const m = remoteUrl.match(/bitbucket\.org[:/](.+?)(?:\.git)?$/);
|
|
255
|
-
if (m)
|
|
256
|
-
return `https://bitbucket.org/${m[1]}/branch/${encoded}`;
|
|
257
|
-
}
|
|
258
|
-
return null;
|
|
259
|
-
}
|
|
260
|
-
catch {
|
|
261
|
-
return null;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
// ============ PR CREATION (PROVIDER-AWARE) ============
|
|
265
|
-
export function buildPrBody(card, commitLog) {
|
|
266
|
-
return [
|
|
267
|
-
"## Summary",
|
|
268
|
-
"",
|
|
269
|
-
`Automated PR for card **#${card.short_id} — ${card.title}**.`,
|
|
270
|
-
"",
|
|
271
|
-
"## Commits",
|
|
272
|
-
"",
|
|
273
|
-
"```",
|
|
274
|
-
commitLog,
|
|
275
|
-
"```",
|
|
276
|
-
"",
|
|
277
|
-
"## Card",
|
|
278
|
-
"",
|
|
279
|
-
card.description?.slice(0, 500) || "No description.",
|
|
280
|
-
"",
|
|
281
|
-
"---",
|
|
282
|
-
"*Created by Harmony Agent Daemon*",
|
|
283
|
-
].join("\n");
|
|
284
|
-
}
|
|
285
|
-
export function createPullRequest(card, branchName, worktreePath, config, provider) {
|
|
286
|
-
let commitLog = "";
|
|
287
|
-
try {
|
|
288
|
-
commitLog = execFileSync("git", ["log", "--oneline", `origin/${config.worktree.baseBranch}..HEAD`], { cwd: worktreePath, encoding: "utf-8" }).trim();
|
|
289
|
-
}
|
|
290
|
-
catch {
|
|
291
|
-
commitLog = "(unable to retrieve commit log)";
|
|
292
|
-
}
|
|
293
|
-
const title = `#${card.short_id} ${card.title}`;
|
|
294
|
-
const body = buildPrBody(card, commitLog);
|
|
295
|
-
const base = config.worktree.baseBranch;
|
|
296
|
-
// Check for existing PR first (rework case)
|
|
297
|
-
const existingUrl = findExistingPr(branchName, worktreePath, provider);
|
|
298
|
-
if (existingUrl) {
|
|
299
|
-
log.info(TAG, `PR already exists for ${branchName}, updating body...`);
|
|
300
|
-
updateExistingPr(branchName, body, worktreePath, provider);
|
|
301
|
-
return existingUrl;
|
|
302
|
-
}
|
|
303
|
-
// No existing PR — create a new one
|
|
304
|
-
try {
|
|
305
|
-
let result;
|
|
306
|
-
switch (provider) {
|
|
307
|
-
case "github":
|
|
308
|
-
result = execFileSync("gh", ["pr", "create", "--title", title, "--body", body, "--base", base], { cwd: worktreePath, encoding: "utf-8" }).trim();
|
|
309
|
-
break;
|
|
310
|
-
case "azure": {
|
|
311
|
-
const azOutput = execFileSync("az", [
|
|
312
|
-
"repos",
|
|
313
|
-
"pr",
|
|
314
|
-
"create",
|
|
315
|
-
"--title",
|
|
316
|
-
title,
|
|
317
|
-
"--description",
|
|
318
|
-
body,
|
|
319
|
-
"--source-branch",
|
|
320
|
-
branchName,
|
|
321
|
-
"--target-branch",
|
|
322
|
-
base,
|
|
323
|
-
"--auto-complete",
|
|
324
|
-
"false",
|
|
325
|
-
], { cwd: worktreePath, encoding: "utf-8" }).trim();
|
|
326
|
-
// az repos pr create returns JSON — extract the URL
|
|
327
|
-
try {
|
|
328
|
-
const parsed = JSON.parse(azOutput);
|
|
329
|
-
result = parsed.remoteUrl ?? parsed.url ?? azOutput;
|
|
330
|
-
}
|
|
331
|
-
catch {
|
|
332
|
-
result = azOutput;
|
|
333
|
-
}
|
|
334
|
-
break;
|
|
335
|
-
}
|
|
336
|
-
case "gitlab":
|
|
337
|
-
result = execFileSync("glab", [
|
|
338
|
-
"mr",
|
|
339
|
-
"create",
|
|
340
|
-
"--title",
|
|
341
|
-
title,
|
|
342
|
-
"--description",
|
|
343
|
-
body,
|
|
344
|
-
"--source-branch",
|
|
345
|
-
branchName,
|
|
346
|
-
"--target-branch",
|
|
347
|
-
base,
|
|
348
|
-
"--no-editor",
|
|
349
|
-
], { cwd: worktreePath, encoding: "utf-8" }).trim();
|
|
350
|
-
break;
|
|
351
|
-
default:
|
|
352
|
-
log.warn(TAG, `No PR CLI for provider "${provider}" — branch pushed but no PR created`);
|
|
353
|
-
return null;
|
|
354
|
-
}
|
|
355
|
-
log.info(TAG, `PR created: ${result}`);
|
|
356
|
-
return result;
|
|
357
|
-
}
|
|
358
|
-
catch (err) {
|
|
359
|
-
log.error(TAG, `Failed to create PR: ${err instanceof Error ? err.message : err}`);
|
|
360
|
-
return null;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
export function findExistingPr(branchName, worktreePath, provider) {
|
|
364
|
-
try {
|
|
365
|
-
switch (provider) {
|
|
366
|
-
case "github":
|
|
367
|
-
return execFileSync("gh", ["pr", "view", branchName, "--json", "url", "--jq", ".url"], { cwd: worktreePath, encoding: "utf-8" }).trim();
|
|
368
|
-
case "gitlab": {
|
|
369
|
-
const json = execFileSync("glab", ["mr", "view", branchName, "--output", "json"], { cwd: worktreePath, encoding: "utf-8" }).trim();
|
|
370
|
-
const parsed = JSON.parse(json);
|
|
371
|
-
return parsed.web_url || null;
|
|
372
|
-
}
|
|
373
|
-
default:
|
|
374
|
-
return null;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
catch {
|
|
378
|
-
return null;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
export function updateExistingPr(branchName, body, worktreePath, provider) {
|
|
382
|
-
try {
|
|
383
|
-
switch (provider) {
|
|
384
|
-
case "github":
|
|
385
|
-
execFileSync("gh", ["pr", "edit", branchName, "--body", body], {
|
|
386
|
-
cwd: worktreePath,
|
|
387
|
-
stdio: "pipe",
|
|
388
|
-
});
|
|
389
|
-
break;
|
|
390
|
-
case "gitlab":
|
|
391
|
-
execFileSync("glab", ["mr", "update", branchName, "--description", body], { cwd: worktreePath, stdio: "pipe" });
|
|
392
|
-
break;
|
|
393
|
-
}
|
|
394
|
-
log.info(TAG, `Updated existing PR body for ${branchName}`);
|
|
395
|
-
}
|
|
396
|
-
catch (err) {
|
|
397
|
-
log.warn(TAG, `Failed to update PR body: ${err instanceof Error ? err.message : err}`);
|
|
398
|
-
}
|
|
399
|
-
}
|