@testchimp/cli 0.1.68 → 0.1.70
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.
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChimpHands CI git: base branch (parent) vs agent branch (testchimp-* only).
|
|
3
|
+
*/
|
|
4
|
+
export declare const BASE_BRANCH_PREFIX = "Base branch: ";
|
|
5
|
+
/** @deprecated Legacy platform line; still parsed for in-flight sessions. */
|
|
6
|
+
export declare const LEGACY_WORKING_BRANCH_PREFIX = "Working branch: ";
|
|
7
|
+
export declare function isAgentBranchName(branch: string): boolean;
|
|
8
|
+
export declare function parseBaseBranchFromPrompt(prompt: string): string | undefined;
|
|
9
|
+
export declare function resolveSessionBaseBranch(prompt: string | undefined, envBaseBranch?: string): string | undefined;
|
|
10
|
+
export declare function buildAgentBranchName(sessionId: string): string;
|
|
11
|
+
type GitRunResult = {
|
|
12
|
+
code: number;
|
|
13
|
+
out: string;
|
|
14
|
+
err: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function runGit(args: string[], env?: NodeJS.ProcessEnv): Promise<GitRunResult>;
|
|
17
|
+
export type EnsureAgentBranchOptions = {
|
|
18
|
+
sessionId: string;
|
|
19
|
+
baseBranch?: string;
|
|
20
|
+
/** Agent branch already reported for this session (bootstrap). */
|
|
21
|
+
existingAgentBranch?: string;
|
|
22
|
+
};
|
|
23
|
+
export type EnsureAgentBranchResult = {
|
|
24
|
+
agentBranch: string;
|
|
25
|
+
baseBranch?: string;
|
|
26
|
+
created: boolean;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Ensures the runner is on a published testchimp-* agent branch.
|
|
30
|
+
* When checked out on the user's base branch, creates testchimp-chimphands-<session> from it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function ensureAgentSessionBranch(opts: EnsureAgentBranchOptions): Promise<EnsureAgentBranchResult>;
|
|
33
|
+
export type EnsurePullRequestOptions = {
|
|
34
|
+
agentBranch: string;
|
|
35
|
+
baseBranch: string;
|
|
36
|
+
title?: string;
|
|
37
|
+
body?: string;
|
|
38
|
+
};
|
|
39
|
+
/** Opens a PR from agent branch → base branch when none exists. Returns PR URL if created/found. */
|
|
40
|
+
export declare function ensurePullRequest(opts: EnsurePullRequestOptions): Promise<string | undefined>;
|
|
41
|
+
export declare function isPrOnlyMode(): boolean;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChimpHands CI git: base branch (parent) vs agent branch (testchimp-* only).
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
export const BASE_BRANCH_PREFIX = "Base branch: ";
|
|
6
|
+
/** @deprecated Legacy platform line; still parsed for in-flight sessions. */
|
|
7
|
+
export const LEGACY_WORKING_BRANCH_PREFIX = "Working branch: ";
|
|
8
|
+
const BASE_BRANCH_LINE = /^(?:Base branch|Working branch):\s*(.+)$/im;
|
|
9
|
+
const AGENT_BRANCH_PREFIX = /^testchimp-/i;
|
|
10
|
+
export function isAgentBranchName(branch) {
|
|
11
|
+
return AGENT_BRANCH_PREFIX.test(branch.trim());
|
|
12
|
+
}
|
|
13
|
+
export function parseBaseBranchFromPrompt(prompt) {
|
|
14
|
+
if (!prompt?.trim())
|
|
15
|
+
return undefined;
|
|
16
|
+
let last;
|
|
17
|
+
for (const line of prompt.split("\n")) {
|
|
18
|
+
const m = line.match(BASE_BRANCH_LINE);
|
|
19
|
+
if (!m?.[1])
|
|
20
|
+
continue;
|
|
21
|
+
const candidate = m[1].trim();
|
|
22
|
+
if (candidate)
|
|
23
|
+
last = candidate;
|
|
24
|
+
}
|
|
25
|
+
return last;
|
|
26
|
+
}
|
|
27
|
+
export function resolveSessionBaseBranch(prompt, envBaseBranch) {
|
|
28
|
+
const fromPrompt = prompt ? parseBaseBranchFromPrompt(prompt) : undefined;
|
|
29
|
+
if (fromPrompt)
|
|
30
|
+
return fromPrompt;
|
|
31
|
+
const fromEnv = envBaseBranch?.trim();
|
|
32
|
+
return fromEnv || undefined;
|
|
33
|
+
}
|
|
34
|
+
export function buildAgentBranchName(sessionId) {
|
|
35
|
+
const slug = sessionId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 10).toLowerCase() || "session";
|
|
36
|
+
return `testchimp-chimphands-${slug}`;
|
|
37
|
+
}
|
|
38
|
+
export function runGit(args, env) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const child = spawn("git", args, {
|
|
41
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
42
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
43
|
+
});
|
|
44
|
+
let out = "";
|
|
45
|
+
let err = "";
|
|
46
|
+
child.stdout.on("data", (d) => {
|
|
47
|
+
out += d.toString();
|
|
48
|
+
});
|
|
49
|
+
child.stderr.on("data", (d) => {
|
|
50
|
+
err += d.toString();
|
|
51
|
+
});
|
|
52
|
+
child.on("close", (code) => resolve({ code: code ?? 1, out, err }));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async function currentBranch() {
|
|
56
|
+
const r = await runGit(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
57
|
+
if (r.code !== 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
const b = r.out.trim();
|
|
60
|
+
return b && b !== "HEAD" ? b : undefined;
|
|
61
|
+
}
|
|
62
|
+
async function isDirtyWorktree() {
|
|
63
|
+
const r = await runGit(["status", "--porcelain"]);
|
|
64
|
+
return r.code === 0 && !!r.out.trim();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Ensures the runner is on a published testchimp-* agent branch.
|
|
68
|
+
* When checked out on the user's base branch, creates testchimp-chimphands-<session> from it.
|
|
69
|
+
*/
|
|
70
|
+
export async function ensureAgentSessionBranch(opts) {
|
|
71
|
+
const baseBranch = opts.baseBranch?.trim() || undefined;
|
|
72
|
+
const existing = opts.existingAgentBranch?.trim();
|
|
73
|
+
if (existing && isAgentBranchName(existing)) {
|
|
74
|
+
await checkoutLocalBranch(existing);
|
|
75
|
+
return { agentBranch: existing, baseBranch, created: false };
|
|
76
|
+
}
|
|
77
|
+
const current = await currentBranch();
|
|
78
|
+
if (current && isAgentBranchName(current)) {
|
|
79
|
+
return { agentBranch: current, baseBranch, created: false };
|
|
80
|
+
}
|
|
81
|
+
const agentBranch = buildAgentBranchName(opts.sessionId);
|
|
82
|
+
const create = await runGit(["checkout", "-b", agentBranch]);
|
|
83
|
+
if (create.code !== 0) {
|
|
84
|
+
throw new Error(`git checkout -b ${agentBranch} failed: ${create.err || create.out || "unknown"}`);
|
|
85
|
+
}
|
|
86
|
+
const push = await runGit(["push", "-u", "origin", "HEAD"]);
|
|
87
|
+
if (push.code !== 0) {
|
|
88
|
+
throw new Error(`git push failed for new agent branch: ${push.err || push.out || "unknown"}`);
|
|
89
|
+
}
|
|
90
|
+
console.error(`ChimpHands created agent branch ${agentBranch}` +
|
|
91
|
+
(baseBranch ? ` from base ${baseBranch}` : ""));
|
|
92
|
+
return { agentBranch, baseBranch, created: true };
|
|
93
|
+
}
|
|
94
|
+
async function checkoutLocalBranch(branch) {
|
|
95
|
+
const r = await runGit(["checkout", branch]);
|
|
96
|
+
if (r.code !== 0) {
|
|
97
|
+
throw new Error(`git checkout ${branch} failed: ${r.err || r.out || "unknown"}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Opens a PR from agent branch → base branch when none exists. Returns PR URL if created/found. */
|
|
101
|
+
export async function ensurePullRequest(opts) {
|
|
102
|
+
const agentBranch = opts.agentBranch.trim();
|
|
103
|
+
const baseBranch = opts.baseBranch.trim();
|
|
104
|
+
if (!agentBranch || !baseBranch || agentBranch === baseBranch)
|
|
105
|
+
return undefined;
|
|
106
|
+
const list = await runGh([
|
|
107
|
+
"pr",
|
|
108
|
+
"list",
|
|
109
|
+
"--head",
|
|
110
|
+
agentBranch,
|
|
111
|
+
"--base",
|
|
112
|
+
baseBranch,
|
|
113
|
+
"--state",
|
|
114
|
+
"open",
|
|
115
|
+
"--json",
|
|
116
|
+
"url",
|
|
117
|
+
"--jq",
|
|
118
|
+
".[0].url",
|
|
119
|
+
]);
|
|
120
|
+
if (list.code === 0 && list.out.trim()) {
|
|
121
|
+
return list.out.trim();
|
|
122
|
+
}
|
|
123
|
+
const dirty = await isDirtyWorktree();
|
|
124
|
+
if (dirty) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const headAhead = await runGit(["rev-list", "--count", `${baseBranch}..${agentBranch}`]);
|
|
128
|
+
const aheadCount = Number.parseInt(headAhead.out.trim(), 10);
|
|
129
|
+
if (headAhead.code !== 0 || !Number.isFinite(aheadCount) || aheadCount <= 0) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
const title = opts.title?.trim() || `ChimpHands: ${agentBranch}`;
|
|
133
|
+
const body = opts.body?.trim() ||
|
|
134
|
+
`Automated changes from ChimpHands session on branch \`${agentBranch}\`.\n\nMerge target: \`${baseBranch}\`.`;
|
|
135
|
+
const create = await runGh([
|
|
136
|
+
"pr",
|
|
137
|
+
"create",
|
|
138
|
+
"--base",
|
|
139
|
+
baseBranch,
|
|
140
|
+
"--head",
|
|
141
|
+
agentBranch,
|
|
142
|
+
"--title",
|
|
143
|
+
title,
|
|
144
|
+
"--body",
|
|
145
|
+
body,
|
|
146
|
+
]);
|
|
147
|
+
if (create.code !== 0) {
|
|
148
|
+
console.error(`ChimpHands gh pr create failed: ${create.err || create.out}`);
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
const url = (create.out || create.err).trim().match(/https:\/\/github\.com\/[^\s]+\/pull\/\d+/)?.[0];
|
|
152
|
+
return url;
|
|
153
|
+
}
|
|
154
|
+
function runGh(args) {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
const child = spawn("gh", args, {
|
|
157
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
158
|
+
env: process.env,
|
|
159
|
+
});
|
|
160
|
+
let out = "";
|
|
161
|
+
let err = "";
|
|
162
|
+
child.stdout.on("data", (d) => {
|
|
163
|
+
out += d.toString();
|
|
164
|
+
});
|
|
165
|
+
child.stderr.on("data", (d) => {
|
|
166
|
+
err += d.toString();
|
|
167
|
+
});
|
|
168
|
+
child.on("close", (code) => resolve({ code: code ?? 1, out, err }));
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
export function isPrOnlyMode() {
|
|
172
|
+
const v = (process.env.CHIMPHANDS_PR_ONLY || "").trim().toLowerCase();
|
|
173
|
+
return v === "1" || v === "true" || v === "yes";
|
|
174
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { buildAgentBranchName, isAgentBranchName, parseBaseBranchFromPrompt, resolveSessionBaseBranch, } from "./agentBranch.js";
|
|
4
|
+
describe("agentBranch", () => {
|
|
5
|
+
it("isAgentBranchName accepts testchimp- only", () => {
|
|
6
|
+
assert.equal(isAgentBranchName("testchimp-foo"), true);
|
|
7
|
+
assert.equal(isAgentBranchName("chimphands-foo"), false);
|
|
8
|
+
assert.equal(isAgentBranchName("feature/foo"), false);
|
|
9
|
+
});
|
|
10
|
+
it("parseBaseBranchFromPrompt reads Base and legacy Working lines", () => {
|
|
11
|
+
assert.equal(parseBaseBranchFromPrompt("run qa\nBase branch: dashboard"), "dashboard");
|
|
12
|
+
assert.equal(parseBaseBranchFromPrompt("Working branch: main"), "main");
|
|
13
|
+
assert.equal(parseBaseBranchFromPrompt("run qa only"), undefined);
|
|
14
|
+
});
|
|
15
|
+
it("resolveSessionBaseBranch prefers prompt over env", () => {
|
|
16
|
+
assert.equal(resolveSessionBaseBranch("Base branch: a", "b"), "a");
|
|
17
|
+
assert.equal(resolveSessionBaseBranch(undefined, "b"), "b");
|
|
18
|
+
});
|
|
19
|
+
it("buildAgentBranchName uses testchimp prefix", () => {
|
|
20
|
+
assert.match(buildAgentBranchName("01ARZ3NDEKTSV4RRFFQ69G5FAV"), /^testchimp-chimphands-/);
|
|
21
|
+
});
|
|
22
|
+
});
|
package/dist/chimphands/run.js
CHANGED
|
@@ -14,6 +14,7 @@ import { getBackendUrl, requireApiKey } from "../core/client.js";
|
|
|
14
14
|
import { isChimphandsLlmRetryEnabled, startLlmRetryProxy } from "./llmRetryProxy.js";
|
|
15
15
|
import { midTurnSteeringKey, releaseUnansweredMidTurnMessages, } from "./midTurnSteering.js";
|
|
16
16
|
import { runSteeringSidecarReply } from "./steeringSidecar.js";
|
|
17
|
+
import { ensureAgentSessionBranch, ensurePullRequest, isAgentBranchName, isPrOnlyMode, resolveSessionBaseBranch, runGit, } from "./agentBranch.js";
|
|
17
18
|
const ROLE_ASSISTANT = "CHIMPHANDS_MESSAGE_ROLE_ASSISTANT";
|
|
18
19
|
const ROLE_TOOL = "CHIMPHANDS_MESSAGE_ROLE_TOOL";
|
|
19
20
|
const ROLE_REASONING = "CHIMPHANDS_MESSAGE_ROLE_REASONING";
|
|
@@ -26,6 +27,8 @@ const OPENCODE_AGENT_ID = "chimphands";
|
|
|
26
27
|
const MID_TURN_DELIVERED_STATUS = "Answering your question while the current step continues…";
|
|
27
28
|
/** Coalesce live token fanout (~6–10 posts/s/session) while UI is attached. */
|
|
28
29
|
const STREAM_POST_MIN_INTERVAL_MS = 150;
|
|
30
|
+
/** Set for the active ChimpHands session (PR base branch + callbacks). */
|
|
31
|
+
let activeSessionGitContext = null;
|
|
29
32
|
function isStreamFanoutRole(role) {
|
|
30
33
|
return role === ROLE_ASSISTANT || role === ROLE_TOOL || role === ROLE_REASONING;
|
|
31
34
|
}
|
|
@@ -67,17 +70,18 @@ const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. Y
|
|
|
67
70
|
- Prefer a short numbered list of concrete questions over a long monologue. One decision gate at a time when possible (especially \`/testchimp project init\` Phase 1).
|
|
68
71
|
|
|
69
72
|
## Repo changes (mandatory)
|
|
70
|
-
- NEVER commit or push directly to the default branch (main/master).
|
|
71
|
-
- This conversation uses ONE
|
|
72
|
-
- If bootstrap lists
|
|
73
|
-
- Only create a NEW branch/PR when (a) no
|
|
74
|
-
- Commit and push on the session
|
|
75
|
-
- Branch names MUST start with \`testchimp-\`
|
|
76
|
-
- When
|
|
73
|
+
- NEVER commit or push directly to the default branch (main/master) or to a user **base branch** (the \`Base branch:\` line in the prompt is the parent to branch *from*, not where you commit).
|
|
74
|
+
- This conversation uses ONE **agent branch** (\`testchimp-*\` only) and ONE pull request targeting the base branch. Reuse them for all follow-up work in this chat.
|
|
75
|
+
- If bootstrap lists an agent branch (\`testchimp-*\`), checkout that branch and push additional commits there — update the same PR.
|
|
76
|
+
- Only create a NEW agent branch/PR when (a) no \`testchimp-*\` branch exists yet for this conversation, or (b) the prior PR was merged/closed (verify with \`gh pr view\`).
|
|
77
|
+
- Commit and push on the session agent branch after meaningful edit batches. When \`CHIMPHANDS_UI_ATTACHED\` is \`false\` (no browser watching live), **commit and push before ending every turn** so the user can review async via PR / Files changed. The host also commits any remaining dirty worktree after each turn and before idle teardown.
|
|
78
|
+
- Branch names for commits and PRs MUST start with \`testchimp-\` (never \`chimphands-*\` or the base branch name).
|
|
79
|
+
- When the prompt includes \`Base branch: <name>\`: the workflow may already have that branch checked out (\`CHIMPHANDS_WORK_BRANCH\`). Create \`git checkout -b testchimp-<scope>\` from it, push, then work only on the \`testchimp-*\` branch. Open PRs with \`gh pr create --base <base-branch> --head <testchimp-branch>\`.
|
|
80
|
+
- When creating a NEW agent branch: create it, then IMMEDIATELY publish it with
|
|
77
81
|
\`git push -u origin <branch>\` BEFORE calling report-branch. Users open the branch URL in the UI —
|
|
78
82
|
do not report a branch that only exists locally (that causes GitHub 404).
|
|
79
83
|
- After the branch is on the remote (and after opening a PR), IMMEDIATELY run:
|
|
80
|
-
\`testchimp chimphands report-branch --branch <
|
|
84
|
+
\`testchimp chimphands report-branch --branch <testchimp-branch> [--pr-url <url>]\`
|
|
81
85
|
- Tell the user which branch you are on and include the PR URL when available.
|
|
82
86
|
- **GitHub auth expiry (self-fix):** Job-start App installation tokens expire after ~1 hour. On \`git push\` / \`gh\` auth failures (401/403 / Authentication failed / write access not granted), run \`testchimp chimphands refresh-git-auth\` then retry — **never** ask the user to paste or reconnect a GitHub token in chat. See skill \`references/chimphands-faq.md\`.
|
|
83
87
|
|
|
@@ -119,6 +123,9 @@ export async function reportWorkingBranch(opts) {
|
|
|
119
123
|
if (!branch) {
|
|
120
124
|
throw new Error("branch is required");
|
|
121
125
|
}
|
|
126
|
+
if (!isAgentBranchName(branch)) {
|
|
127
|
+
throw new Error("branch must start with testchimp- (agent feature branch, not the base branch)");
|
|
128
|
+
}
|
|
122
129
|
const body = {
|
|
123
130
|
sessionId: opts.sessionId.trim(),
|
|
124
131
|
workingBranch: branch,
|
|
@@ -966,10 +973,13 @@ function isAssistantEchoOfSentPrompt(assistant, ...sentPrompts) {
|
|
|
966
973
|
}
|
|
967
974
|
return false;
|
|
968
975
|
}
|
|
969
|
-
function wrapPromptWithContext(conversationSummary, userPrompt, isNewOpencodeSession, workingBranch, pullRequestUrl) {
|
|
976
|
+
function wrapPromptWithContext(conversationSummary, userPrompt, isNewOpencodeSession, workingBranch, pullRequestUrl, baseBranch) {
|
|
970
977
|
const parts = [];
|
|
971
|
-
if (
|
|
972
|
-
parts.push("##
|
|
978
|
+
if (baseBranch?.trim()) {
|
|
979
|
+
parts.push("## Base branch (parent — do NOT commit here)", `Base branch: \`${baseBranch.trim()}\``, "Create a `testchimp-*` branch from this parent before any file edits. PRs must target this base branch.", "");
|
|
980
|
+
}
|
|
981
|
+
if (workingBranch?.trim() && isAgentBranchName(workingBranch)) {
|
|
982
|
+
parts.push("## Conversation agent branch (reuse for this thread)", `Branch: \`${workingBranch.trim()}\``, pullRequestUrl?.trim() ? `PR: ${pullRequestUrl.trim()}` : "", "Checkout this testchimp-* branch, commit and push here. Do NOT open a new PR unless the one above was merged/closed.", "");
|
|
973
983
|
}
|
|
974
984
|
const task = normalizeUserMessage(userPrompt);
|
|
975
985
|
// Do NOT inject "Interactive turn reminder" into the user prompt — it is already in
|
|
@@ -992,7 +1002,7 @@ function detectWorkingBranchFromToolOutput(output) {
|
|
|
992
1002
|
const prMatch = text.match(/https:\/\/github\.com\/[^\s)\]]+\/pull\/\d+/);
|
|
993
1003
|
// Only auto-detect after a successful push to origin — local checkout -b alone would
|
|
994
1004
|
// report a branch URL that 404s until the remote ref exists.
|
|
995
|
-
const pushMatch = text.match(/push\s+(?:--set-upstream\s+|-u\s+)?origin\s+((?:testchimp
|
|
1005
|
+
const pushMatch = text.match(/push\s+(?:--set-upstream\s+|-u\s+)?origin\s+((?:testchimp-)[^\s'"\[\]]+)/i);
|
|
996
1006
|
const branch = pushMatch?.[1]?.replace(/[`'"\[\]]/g, "");
|
|
997
1007
|
return {
|
|
998
1008
|
branch,
|
|
@@ -1537,6 +1547,11 @@ async function applyUserFileEdit(backend, apiKey, sessionId, edit, turnActive) {
|
|
|
1537
1547
|
}
|
|
1538
1548
|
await fs.mkdir(path.dirname(full), { recursive: true });
|
|
1539
1549
|
await fs.writeFile(full, edit.content, "utf8");
|
|
1550
|
+
const pushed = await commitAndPushDirtyWorktree("User Update");
|
|
1551
|
+
if (!pushed.ok) {
|
|
1552
|
+
await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, false, pushed.error || "commit/push failed");
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1540
1555
|
await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, true);
|
|
1541
1556
|
}
|
|
1542
1557
|
catch (err) {
|
|
@@ -1839,9 +1854,16 @@ export async function runChimphands(opts) {
|
|
|
1839
1854
|
const prIsNew = !!prUrl?.trim() && prUrl.trim() !== pullRequestUrl;
|
|
1840
1855
|
if (workingBranch === normalizedBranch && !prIsNew)
|
|
1841
1856
|
return;
|
|
1857
|
+
if (!isAgentBranchName(normalizedBranch))
|
|
1858
|
+
return;
|
|
1842
1859
|
workingBranch = normalizedBranch;
|
|
1843
1860
|
if (prUrl?.trim())
|
|
1844
1861
|
pullRequestUrl = prUrl.trim();
|
|
1862
|
+
if (activeSessionGitContext) {
|
|
1863
|
+
activeSessionGitContext.agentBranch = normalizedBranch;
|
|
1864
|
+
if (pullRequestUrl)
|
|
1865
|
+
activeSessionGitContext.pullRequestUrl = pullRequestUrl;
|
|
1866
|
+
}
|
|
1845
1867
|
if (branchIsNew || prIsNew) {
|
|
1846
1868
|
poster.reportWorkingBranch(normalizedBranch, nextPr);
|
|
1847
1869
|
}
|
|
@@ -2108,6 +2130,7 @@ export async function runChimphands(opts) {
|
|
|
2108
2130
|
status: "CHIMPHANDS_RUNTIME_STATUS_TERMINATED",
|
|
2109
2131
|
}).catch(() => { });
|
|
2110
2132
|
}
|
|
2133
|
+
activeSessionGitContext = null;
|
|
2111
2134
|
};
|
|
2112
2135
|
try {
|
|
2113
2136
|
poster.fireAndForget(ROLE_STATUS, "Agent ready", { status: STATUS_RUNNING });
|
|
@@ -2129,6 +2152,32 @@ export async function runChimphands(opts) {
|
|
|
2129
2152
|
undefined;
|
|
2130
2153
|
enqueueUserMessage({ id: id || undefined, content });
|
|
2131
2154
|
}
|
|
2155
|
+
let sessionBaseBranch = resolveSessionBaseBranch(prompt, process.env.CHIMPHANDS_WORK_BRANCH);
|
|
2156
|
+
activeSessionGitContext = {
|
|
2157
|
+
baseBranch: sessionBaseBranch,
|
|
2158
|
+
agentBranch: workingBranch && isAgentBranchName(workingBranch) ? workingBranch : undefined,
|
|
2159
|
+
pullRequestUrl,
|
|
2160
|
+
onPullRequest: (url) => {
|
|
2161
|
+
if (workingBranch)
|
|
2162
|
+
noteWorkingBranch(workingBranch, url);
|
|
2163
|
+
},
|
|
2164
|
+
};
|
|
2165
|
+
try {
|
|
2166
|
+
const branchSetup = await ensureAgentSessionBranch({
|
|
2167
|
+
sessionId,
|
|
2168
|
+
baseBranch: sessionBaseBranch,
|
|
2169
|
+
existingAgentBranch: workingBranch && isAgentBranchName(workingBranch) ? workingBranch : undefined,
|
|
2170
|
+
});
|
|
2171
|
+
sessionBaseBranch = branchSetup.baseBranch || sessionBaseBranch;
|
|
2172
|
+
activeSessionGitContext.baseBranch = sessionBaseBranch;
|
|
2173
|
+
activeSessionGitContext.agentBranch = branchSetup.agentBranch;
|
|
2174
|
+
if (!workingBranch || workingBranch !== branchSetup.agentBranch) {
|
|
2175
|
+
noteWorkingBranch(branchSetup.agentBranch);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
catch (err) {
|
|
2179
|
+
console.error(`ChimpHands ensure agent branch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2180
|
+
}
|
|
2132
2181
|
const waitForNextPrompt = () => new Promise((resolve) => {
|
|
2133
2182
|
let lastPollAt = 0;
|
|
2134
2183
|
let settled = false;
|
|
@@ -2184,7 +2233,7 @@ export async function runChimphands(opts) {
|
|
|
2184
2233
|
flushPendingMidTurnInject();
|
|
2185
2234
|
let useOpencodeSessionId = opencodeSessionId;
|
|
2186
2235
|
let isNewOpencodeSession = !useOpencodeSessionId;
|
|
2187
|
-
let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
|
|
2236
|
+
let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl, sessionBaseBranch);
|
|
2188
2237
|
const turnPostEvent = (role, content, opts) => postEventForTurn(prompt, effectivePrompt, role, content, opts);
|
|
2189
2238
|
// Visible in chat (not filtered as routine). OpenCode may not emit text until a
|
|
2190
2239
|
// part completes — without this the UI looks empty while the turn is running.
|
|
@@ -2208,7 +2257,7 @@ export async function runChimphands(opts) {
|
|
|
2208
2257
|
console.error(`ChimpHands OpenCode session ${useOpencodeSessionId} missing on runner; starting fresh thread.`);
|
|
2209
2258
|
opencodeSessionId = undefined;
|
|
2210
2259
|
isNewOpencodeSession = true;
|
|
2211
|
-
effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, true, workingBranch, pullRequestUrl);
|
|
2260
|
+
effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, true, workingBranch, pullRequestUrl, sessionBaseBranch);
|
|
2212
2261
|
result = await runOpencode(effectivePrompt, opencodeModel, childEnv, undefined, {
|
|
2213
2262
|
onSessionId: (id) => {
|
|
2214
2263
|
opencodeSessionId = id;
|
|
@@ -2345,46 +2394,39 @@ function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
|
|
|
2345
2394
|
stopped = true;
|
|
2346
2395
|
};
|
|
2347
2396
|
}
|
|
2348
|
-
/** Commit+push dirty worktree on the session branch before idle/teardown
|
|
2397
|
+
/** Commit+push dirty worktree on the session agent branch before idle/teardown. */
|
|
2349
2398
|
async function commitAndPushDirtyWorktree(message) {
|
|
2350
|
-
const run = (args, env) =>
|
|
2351
|
-
const child = spawn("git", args, {
|
|
2352
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
2353
|
-
env: env ? { ...process.env, ...env } : process.env,
|
|
2354
|
-
});
|
|
2355
|
-
let out = "";
|
|
2356
|
-
let err = "";
|
|
2357
|
-
child.stdout.on("data", (d) => {
|
|
2358
|
-
out += d.toString();
|
|
2359
|
-
});
|
|
2360
|
-
child.stderr.on("data", (d) => {
|
|
2361
|
-
err += d.toString();
|
|
2362
|
-
});
|
|
2363
|
-
child.on("close", (code) => resolve({ code: code ?? 1, out, err }));
|
|
2364
|
-
});
|
|
2399
|
+
const run = (args, env) => runGit(args, env);
|
|
2365
2400
|
try {
|
|
2366
2401
|
const branch = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
2367
2402
|
if (branch.code !== 0) {
|
|
2368
|
-
|
|
2369
|
-
|
|
2403
|
+
const error = branch.err || branch.out || "git rev-parse failed";
|
|
2404
|
+
console.error(`ChimpHands git rev-parse failed: ${error}`);
|
|
2405
|
+
return { ok: false, error };
|
|
2370
2406
|
}
|
|
2371
2407
|
const current = branch.out.trim();
|
|
2372
|
-
if (!current || current === "HEAD" ||
|
|
2373
|
-
|
|
2374
|
-
|
|
2408
|
+
if (!current || current === "HEAD" || !isAgentBranchName(current)) {
|
|
2409
|
+
const error = `refusing non-agent branch "${current || "(unknown)"}" (expected testchimp-*)`;
|
|
2410
|
+
console.error(`ChimpHands skip commit-before-idle: ${error}`);
|
|
2411
|
+
return { ok: false, error };
|
|
2412
|
+
}
|
|
2413
|
+
if (activeSessionGitContext) {
|
|
2414
|
+
activeSessionGitContext.agentBranch = current;
|
|
2375
2415
|
}
|
|
2376
2416
|
const status = await run(["status", "--porcelain"]);
|
|
2377
2417
|
if (status.code !== 0) {
|
|
2378
|
-
|
|
2379
|
-
|
|
2418
|
+
const error = status.err || status.out || "git status failed";
|
|
2419
|
+
console.error(`ChimpHands git status failed: ${error}`);
|
|
2420
|
+
return { ok: false, error };
|
|
2380
2421
|
}
|
|
2381
2422
|
if (!status.out.trim()) {
|
|
2382
|
-
return;
|
|
2423
|
+
return { ok: true };
|
|
2383
2424
|
}
|
|
2384
2425
|
const add = await run(["add", "-A"]);
|
|
2385
2426
|
if (add.code !== 0) {
|
|
2386
|
-
|
|
2387
|
-
|
|
2427
|
+
const error = add.err || add.out || "git add failed";
|
|
2428
|
+
console.error(`ChimpHands git add failed: ${error}`);
|
|
2429
|
+
return { ok: false, error };
|
|
2388
2430
|
}
|
|
2389
2431
|
const commitEnv = {
|
|
2390
2432
|
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || "ChimpHands",
|
|
@@ -2394,8 +2436,9 @@ async function commitAndPushDirtyWorktree(message) {
|
|
|
2394
2436
|
};
|
|
2395
2437
|
const commit = await run(["-c", "user.name=ChimpHands", "-c", "user.email=chimphands@testchimp.io", "commit", "-m", message], commitEnv);
|
|
2396
2438
|
if (commit.code !== 0) {
|
|
2397
|
-
|
|
2398
|
-
|
|
2439
|
+
const error = commit.err || commit.out || "git commit failed";
|
|
2440
|
+
console.error(`ChimpHands git commit: ${error}`);
|
|
2441
|
+
return { ok: false, error };
|
|
2399
2442
|
}
|
|
2400
2443
|
let push = await run(["push", "-u", "origin", "HEAD"]);
|
|
2401
2444
|
if (push.code !== 0) {
|
|
@@ -2408,23 +2451,44 @@ async function commitAndPushDirtyWorktree(message) {
|
|
|
2408
2451
|
await refreshGitAuth();
|
|
2409
2452
|
push = await run(["push", "-u", "origin", "HEAD"]);
|
|
2410
2453
|
if (push.code !== 0) {
|
|
2411
|
-
|
|
2412
|
-
|
|
2454
|
+
const error = push.err || push.out || "git push failed after refresh";
|
|
2455
|
+
console.error(`ChimpHands git push failed after refresh: ${error}`);
|
|
2456
|
+
return { ok: false, error };
|
|
2413
2457
|
}
|
|
2414
2458
|
}
|
|
2415
2459
|
else {
|
|
2416
|
-
return;
|
|
2460
|
+
return { ok: false, error: push.err || push.out || "git push failed" };
|
|
2417
2461
|
}
|
|
2418
2462
|
}
|
|
2419
2463
|
catch (refreshErr) {
|
|
2420
|
-
|
|
2421
|
-
|
|
2464
|
+
const error = refreshErr instanceof Error ? refreshErr.message : String(refreshErr);
|
|
2465
|
+
console.error(`ChimpHands refresh-git-auth after push failure: ${error}`);
|
|
2466
|
+
return { ok: false, error };
|
|
2422
2467
|
}
|
|
2423
2468
|
}
|
|
2424
2469
|
console.error(`ChimpHands committed and pushed dirty worktree on ${current} before shutdown`);
|
|
2470
|
+
const ctx = activeSessionGitContext;
|
|
2471
|
+
if (ctx &&
|
|
2472
|
+
isPrOnlyMode() &&
|
|
2473
|
+
!ctx.pullRequestUrl &&
|
|
2474
|
+
ctx.baseBranch &&
|
|
2475
|
+
isAgentBranchName(current)) {
|
|
2476
|
+
const prUrl = await ensurePullRequest({
|
|
2477
|
+
agentBranch: current,
|
|
2478
|
+
baseBranch: ctx.baseBranch,
|
|
2479
|
+
});
|
|
2480
|
+
if (prUrl) {
|
|
2481
|
+
ctx.pullRequestUrl = prUrl;
|
|
2482
|
+
ctx.onPullRequest?.(prUrl);
|
|
2483
|
+
console.error(`ChimpHands opened PR: ${prUrl}`);
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
return { ok: true };
|
|
2425
2487
|
}
|
|
2426
2488
|
catch (err) {
|
|
2427
|
-
|
|
2489
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
2490
|
+
console.error(`ChimpHands commit-before-idle failed: ${error}`);
|
|
2491
|
+
return { ok: false, error };
|
|
2428
2492
|
}
|
|
2429
2493
|
}
|
|
2430
2494
|
function opencodeProxyNeedsDirectory(path) {
|
package/dist/cli/program.js
CHANGED
|
@@ -1607,7 +1607,7 @@ export function buildCliProgram() {
|
|
|
1607
1607
|
chimphands
|
|
1608
1608
|
.command("report-branch")
|
|
1609
1609
|
.description("Report the conversation working branch (and optional PR URL) to TestChimp")
|
|
1610
|
-
.requiredOption("--branch <name>", "
|
|
1610
|
+
.requiredOption("--branch <name>", "Agent feature branch name (testchimp-* only)")
|
|
1611
1611
|
.option("--pr-url <url>", "Open pull request URL")
|
|
1612
1612
|
.option("--session-id <id>", "ChimpHands session id (or SESSION_ID env)")
|
|
1613
1613
|
.action(async (opts) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testchimp/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.70",
|
|
4
4
|
"description": "TestChimp CLI and MCP server — coverage, plans, EaaS, TrueCoverage, API operations (calls /api/mcp/*)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/bin/testchimp.js",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsc",
|
|
16
|
-
"test": "npm run build && node --test dist/chimphands/llmRetryProxy.test.js dist/chimphands/midTurnSteering.test.js dist/chimphands/steeringSidecar.test.js",
|
|
16
|
+
"test": "npm run build && node --test dist/chimphands/llmRetryProxy.test.js dist/chimphands/midTurnSteering.test.js dist/chimphands/steeringSidecar.test.js dist/chimphands/agentBranch.test.js",
|
|
17
17
|
"watch": "tsc --watch",
|
|
18
18
|
"start": "node dist/bin/testchimp.js",
|
|
19
19
|
"prepublishOnly": "npm run build"
|