@testchimp/cli 0.1.76 → 0.1.78

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.
@@ -6,6 +6,7 @@
6
6
  import { execSync, spawn } from "node:child_process";
7
7
  import { mkdirSync, openSync, writeFileSync } from "node:fs";
8
8
  import fs from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
9
10
  import path from "node:path";
10
11
  import http from "node:http";
11
12
  import https from "node:https";
@@ -15,6 +16,9 @@ import { isChimphandsLlmRetryEnabled, startLlmRetryProxy } from "./llmRetryProxy
15
16
  import { midTurnSteeringKey, releaseUnansweredMidTurnMessages, } from "./midTurnSteering.js";
16
17
  import { runSteeringSidecarReply } from "./steeringSidecar.js";
17
18
  import { ensureAgentSessionBranch, ensurePullRequest, isAgentBranchName, isPrOnlyMode, resolveSessionBaseBranch, runGit, } from "./agentBranch.js";
19
+ import { isChimpHandsInternalCommitPath, resolveCommitPaths, } from "./worktreeCommitPaths.js";
20
+ /** Runner logs live outside the git worktree so they never enter commit status. */
21
+ const OPENCODE_SERVER_LOG_PATH = path.join(tmpdir(), "chimphands-opencode-server.log");
18
22
  const ROLE_ASSISTANT = "CHIMPHANDS_MESSAGE_ROLE_ASSISTANT";
19
23
  const ROLE_TOOL = "CHIMPHANDS_MESSAGE_ROLE_TOOL";
20
24
  const ROLE_REASONING = "CHIMPHANDS_MESSAGE_ROLE_REASONING";
@@ -98,7 +102,7 @@ const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. Y
98
102
 
99
103
  ## CI runner hygiene (GitHub Actions)
100
104
  - On hosted runners: load skill \`references/chimphands-ci-runner.md\` before compile, Docker builds, or large installs.
101
- - Never \`git fetch origin\` (full fetch) — shallow single-branch fetch only; branch names are in \`.chimphands/remote-branch-names.txt\`.
105
+ - Never \`git fetch origin\` (full fetch) — shallow single-branch fetch only; branch names are in \`/tmp/chimphands/remote-branch-names.txt\` (or \`$CHIMPHANDS_REMOTE_BRANCH_NAMES\`).
102
106
  - Reclaim disk between heavy phases (\`docker builder prune -af\` after image builds, stop compile daemons, delete temp/test artifacts). One reclaim+retry on ENOSPC — no blind loops.
103
107
 
104
108
  ## Honesty
@@ -1101,6 +1105,10 @@ function writeOpencodeConfig(backend, apiKey, boot, llmBaseOverride) {
1101
1105
  TESTCHIMP_EXECUTION_SOURCE: "CLOUD_AGENT",
1102
1106
  CHIMPHANDS_UI_ATTACHED: uiAttached ? "true" : "false",
1103
1107
  };
1108
+ if (sessionId) {
1109
+ mcpEnv.SESSION_ID = sessionId;
1110
+ mcpEnv.CHIMPHANDS_SESSION_ID = sessionId;
1111
+ }
1104
1112
  const serviceUserId = bootStr(boot, "chimphands_service_account_user_id", "chimphandsServiceAccountUserId");
1105
1113
  if (serviceUserId) {
1106
1114
  mcpEnv.TESTCHIMP_USER_ID = serviceUserId;
@@ -1312,7 +1320,7 @@ async function restartLocalOpencodeServer(attachUrl) {
1312
1320
  const port = u.port || (u.protocol === "https:" ? "443" : "80");
1313
1321
  killListenersOnPort(port);
1314
1322
  await sleep(300);
1315
- const logFd = openSync("opencode-server.log", "a");
1323
+ const logFd = openSync(OPENCODE_SERVER_LOG_PATH, "a");
1316
1324
  const child = spawn("opencode", ["serve", "--port", port, "--hostname", hostname], {
1317
1325
  detached: true,
1318
1326
  stdio: ["ignore", logFd, logFd],
@@ -1327,6 +1335,7 @@ async function restartLocalOpencodeServer(attachUrl) {
1327
1335
  /* best effort */
1328
1336
  }
1329
1337
  }
1338
+ console.error(`ChimpHands opencode serve log: ${OPENCODE_SERVER_LOG_PATH}`);
1330
1339
  console.error(`ChimpHands restarted OpenCode serve on ${hostname}:${port} (pid ${child.pid ?? "?"})`);
1331
1340
  await waitForOpencodeHttp(attachUrl, 60_000);
1332
1341
  }
@@ -1627,8 +1636,13 @@ async function ackWorktreeFileWrite(backend, apiKey, sessionId, requestId, ok, e
1627
1636
  requestId,
1628
1637
  ok,
1629
1638
  };
1630
- if (errorMessage)
1631
- body.errorMessage = errorMessage.slice(0, 2000);
1639
+ if (errorMessage) {
1640
+ body.errorMessage = errorMessage
1641
+ .replace(/[\r\n\t]+/g, " ")
1642
+ .replace(/ +/g, " ")
1643
+ .trim()
1644
+ .slice(0, 2000);
1645
+ }
1632
1646
  await postJson(backend, apiKey, "/api/chimphands/ack_worktree_file_write", body).catch((err) => {
1633
1647
  console.error(`ChimpHands ack_worktree_file_write failed: ${err instanceof Error ? err.message : String(err)}`);
1634
1648
  });
@@ -1651,7 +1665,9 @@ async function applyUserFileEdit(backend, apiKey, sessionId, edit, turnActive) {
1651
1665
  await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, true);
1652
1666
  return;
1653
1667
  }
1654
- const pushed = await commitAndPushDirtyWorktree("User Update");
1668
+ const pushed = await commitAndPushDirtyWorktree("User Update", {
1669
+ paths: [relative],
1670
+ });
1655
1671
  if (!pushed.ok) {
1656
1672
  await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, false, pushed.error || "commit/push failed");
1657
1673
  return;
@@ -2180,6 +2196,8 @@ export async function runChimphands(opts) {
2180
2196
  TESTCHIMP_API_KEY: apiKey,
2181
2197
  TESTCHIMP_BACKEND_URL: backend,
2182
2198
  TESTCHIMP_EXECUTION_SOURCE: "CLOUD_AGENT",
2199
+ SESSION_ID: sessionId,
2200
+ CHIMPHANDS_SESSION_ID: sessionId,
2183
2201
  };
2184
2202
  if (userId)
2185
2203
  childEnv.TESTCHIMP_USER_ID = userId;
@@ -2509,41 +2527,15 @@ function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
2509
2527
  stopped = true;
2510
2528
  };
2511
2529
  }
2512
- /** Paths ChimpHands writes on the runner — never commit into the agent branch. */
2513
- const CHIMPHANDS_COMMIT_EXCLUDE_PATHSPECS = [
2514
- ":(exclude)opencode.json",
2515
- ":(exclude)opencode-server.log",
2516
- ":(exclude)chimphands-run.log",
2517
- ":(exclude)remote-branches.txt",
2518
- ":(exclude)remote-branch-names.txt",
2519
- ":(exclude)gha-creds*",
2520
- ":(exclude)**/gha-creds*",
2521
- ":(exclude).chimphands",
2522
- ":(exclude).chimphands/**",
2523
- ":(exclude).agents",
2524
- ":(exclude).agents/**",
2525
- ];
2526
- function isChimpHandsInternalCommitPath(filePath) {
2527
- let normalized = filePath.replace(/\\/g, "/").toLowerCase();
2528
- while (normalized.startsWith("./"))
2529
- normalized = normalized.slice(2);
2530
- const slash = normalized.lastIndexOf("/");
2531
- const name = slash >= 0 ? normalized.slice(slash + 1) : normalized;
2532
- if (name === "opencode.json" ||
2533
- name === "opencode-server.log" ||
2534
- name === "chimphands-run.log" ||
2535
- name === "remote-branches.txt" ||
2536
- name === "remote-branch-names.txt" ||
2537
- name.startsWith("gha-creds")) {
2538
- return true;
2539
- }
2540
- return (normalized === ".chimphands" ||
2541
- normalized.startsWith(".chimphands/") ||
2542
- normalized === ".agents" ||
2543
- normalized.startsWith(".agents/"));
2544
- }
2545
- /** Commit+push dirty worktree on the session agent branch before idle/teardown. */
2546
- async function commitAndPushDirtyWorktree(message) {
2530
+ /**
2531
+ * Commit+push product changes on the session agent branch.
2532
+ *
2533
+ * Policy: allowlist staging from `git status` (never `git add -A` / exclude pathspecs).
2534
+ * Runner scaffolding is filtered out; anything already staged by the agent that is
2535
+ * scaffolding is unstaged before commit. Session logs are written under os.tmpdir(),
2536
+ * not the worktree.
2537
+ */
2538
+ async function commitAndPushDirtyWorktree(message, opts) {
2547
2539
  const run = (args, env) => runGit(args, env);
2548
2540
  try {
2549
2541
  const branch = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
@@ -2567,35 +2559,16 @@ async function commitAndPushDirtyWorktree(message) {
2567
2559
  console.error(`ChimpHands git status failed: ${error}`);
2568
2560
  return { ok: false, error };
2569
2561
  }
2570
- if (!status.out.trim()) {
2571
- return { ok: true };
2572
- }
2573
- const add = await run([
2574
- "add",
2575
- "-A",
2576
- "--",
2577
- ".",
2578
- ...CHIMPHANDS_COMMIT_EXCLUDE_PATHSPECS,
2579
- ]);
2580
- if (add.code !== 0) {
2581
- const error = add.err || add.out || "git add failed";
2582
- console.error(`ChimpHands git add failed: ${error}`);
2583
- return { ok: false, error };
2562
+ const toStage = resolveCommitPaths(status.out, opts?.paths);
2563
+ if (toStage.length) {
2564
+ const add = await run(["add", "--", ...toStage]);
2565
+ if (add.code !== 0) {
2566
+ const error = add.err || add.out || "git add failed";
2567
+ console.error(`ChimpHands git add failed: ${error}`);
2568
+ return { ok: false, error };
2569
+ }
2584
2570
  }
2585
- // Drop already-tracked scaffolding that may still be staged from older commits.
2586
- await run([
2587
- "reset",
2588
- "-q",
2589
- "HEAD",
2590
- "--",
2591
- "opencode.json",
2592
- "opencode-server.log",
2593
- "chimphands-run.log",
2594
- "remote-branches.txt",
2595
- "remote-branch-names.txt",
2596
- ".chimphands",
2597
- ".agents",
2598
- ]);
2571
+ // Drop scaffolding the agent may have staged (git add -A, etc.).
2599
2572
  const staged = await run(["diff", "--cached", "--name-only"]);
2600
2573
  if (staged.code !== 0) {
2601
2574
  const error = staged.err || staged.out || "git diff --cached failed";
@@ -2611,7 +2584,13 @@ async function commitAndPushDirtyWorktree(message) {
2611
2584
  await run(["reset", "-q", "HEAD", "--", ...internalStaged]);
2612
2585
  }
2613
2586
  const keepStaged = stagedPaths.filter((p) => !isChimpHandsInternalCommitPath(p));
2614
- if (!keepStaged.length) {
2587
+ const pathScoped = !!opts?.paths?.length;
2588
+ if (pathScoped) {
2589
+ if (!toStage.length) {
2590
+ return { ok: true };
2591
+ }
2592
+ }
2593
+ else if (!keepStaged.length) {
2615
2594
  return { ok: true };
2616
2595
  }
2617
2596
  const commitEnv = {
@@ -2620,7 +2599,30 @@ async function commitAndPushDirtyWorktree(message) {
2620
2599
  GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || "ChimpHands",
2621
2600
  GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || "chimphands@testchimp.io",
2622
2601
  };
2623
- const commit = await run(["-c", "user.name=ChimpHands", "-c", "user.email=chimphands@testchimp.io", "commit", "-m", message], commitEnv);
2602
+ // Pathspec form is `git commit --only` — commits just those paths even if
2603
+ // the index has other staged product files.
2604
+ const commitArgs = pathScoped
2605
+ ? [
2606
+ "-c",
2607
+ "user.name=ChimpHands",
2608
+ "-c",
2609
+ "user.email=chimphands@testchimp.io",
2610
+ "commit",
2611
+ "-m",
2612
+ message,
2613
+ "--",
2614
+ ...toStage,
2615
+ ]
2616
+ : [
2617
+ "-c",
2618
+ "user.name=ChimpHands",
2619
+ "-c",
2620
+ "user.email=chimphands@testchimp.io",
2621
+ "commit",
2622
+ "-m",
2623
+ message,
2624
+ ];
2625
+ const commit = await run(commitArgs, commitEnv);
2624
2626
  if (commit.code !== 0) {
2625
2627
  const error = commit.err || commit.out || "git commit failed";
2626
2628
  console.error(`ChimpHands git commit: ${error}`);
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Paths that must not be committed on the agent branch.
3
+ *
4
+ * Worktree-resident (needed at runtime, never product):
5
+ * - opencode.json, .agents/**
6
+ *
7
+ * Legacy / accidental worktree pollution (now written under /tmp when possible):
8
+ * - *.log runner logs, .chimphands/**, remote-branch*.txt, gha-creds*
9
+ */
10
+ export declare function isChimpHandsInternalCommitPath(filePath: string): boolean;
11
+ /** Parse paths from `git status --porcelain` (newline form, not -z). */
12
+ export declare function pathsFromGitPorcelain(porcelain: string): string[];
13
+ /** Dirty worktree paths that are eligible to commit on the agent branch. */
14
+ export declare function commitCandidatePathsFromPorcelain(porcelain: string): string[];
15
+ export declare function normalizeWorktreePath(filePath: string): string;
16
+ /** Intersect status candidates with an optional path allowlist (e.g. a single UI save). */
17
+ export declare function resolveCommitPaths(porcelain: string, onlyPaths?: string[]): string[];
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Paths that must not be committed on the agent branch.
3
+ *
4
+ * Worktree-resident (needed at runtime, never product):
5
+ * - opencode.json, .agents/**
6
+ *
7
+ * Legacy / accidental worktree pollution (now written under /tmp when possible):
8
+ * - *.log runner logs, .chimphands/**, remote-branch*.txt, gha-creds*
9
+ */
10
+ export function isChimpHandsInternalCommitPath(filePath) {
11
+ let normalized = filePath.replace(/\\/g, "/").toLowerCase();
12
+ while (normalized.startsWith("./"))
13
+ normalized = normalized.slice(2);
14
+ const slash = normalized.lastIndexOf("/");
15
+ const name = slash >= 0 ? normalized.slice(slash + 1) : normalized;
16
+ if (name === "opencode.json" ||
17
+ name === "opencode-server.log" ||
18
+ name === "chimphands-run.log" ||
19
+ name === "remote-branches.txt" ||
20
+ name === "remote-branch-names.txt" ||
21
+ name.startsWith("gha-creds")) {
22
+ return true;
23
+ }
24
+ return (normalized === ".chimphands" ||
25
+ normalized.startsWith(".chimphands/") ||
26
+ normalized === ".agents" ||
27
+ normalized.startsWith(".agents/"));
28
+ }
29
+ /** Parse paths from `git status --porcelain` (newline form, not -z). */
30
+ export function pathsFromGitPorcelain(porcelain) {
31
+ const paths = [];
32
+ for (const raw of porcelain.split("\n")) {
33
+ if (!raw || raw.length < 4)
34
+ continue;
35
+ let body = raw.slice(3);
36
+ if (body.includes(" -> ")) {
37
+ body = body.slice(body.lastIndexOf(" -> ") + 4);
38
+ }
39
+ if (body.startsWith('"') && body.endsWith('"')) {
40
+ body = body
41
+ .slice(1, -1)
42
+ .replace(/\\([\\"nt])/g, (_m, c) => {
43
+ if (c === "n")
44
+ return "\n";
45
+ if (c === "t")
46
+ return "\t";
47
+ return c;
48
+ });
49
+ }
50
+ if (body)
51
+ paths.push(body);
52
+ }
53
+ return paths;
54
+ }
55
+ /** Dirty worktree paths that are eligible to commit on the agent branch. */
56
+ export function commitCandidatePathsFromPorcelain(porcelain) {
57
+ return pathsFromGitPorcelain(porcelain).filter((p) => !isChimpHandsInternalCommitPath(p));
58
+ }
59
+ export function normalizeWorktreePath(filePath) {
60
+ let normalized = filePath.replace(/\\/g, "/");
61
+ while (normalized.startsWith("./"))
62
+ normalized = normalized.slice(2);
63
+ return normalized;
64
+ }
65
+ /** Intersect status candidates with an optional path allowlist (e.g. a single UI save). */
66
+ export function resolveCommitPaths(porcelain, onlyPaths) {
67
+ const candidates = commitCandidatePathsFromPorcelain(porcelain);
68
+ if (!onlyPaths?.length)
69
+ return candidates;
70
+ const want = new Set(onlyPaths.map(normalizeWorktreePath));
71
+ return candidates.filter((p) => want.has(normalizeWorktreePath(p)));
72
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { commitCandidatePathsFromPorcelain, isChimpHandsInternalCommitPath, pathsFromGitPorcelain, resolveCommitPaths, } from "./worktreeCommitPaths.js";
4
+ describe("worktreeCommitPaths", () => {
5
+ it("parses porcelain paths and rename destinations", () => {
6
+ assert.deepEqual(pathsFromGitPorcelain([" M ui/plans/foo.md", "?? chimphands-run.log", "R old.md -> new.md"].join("\n")), ["ui/plans/foo.md", "chimphands-run.log", "new.md"]);
7
+ });
8
+ it("allowlists only product paths for commit", () => {
9
+ assert.deepEqual(commitCandidatePathsFromPorcelain([
10
+ " M ui/plans/stories/x.md",
11
+ "?? chimphands-run.log",
12
+ "?? opencode-server.log",
13
+ "?? opencode.json",
14
+ " M src/app.ts",
15
+ "?? .agents/skills/testchimp/SKILL.md",
16
+ ].join("\n")), ["ui/plans/stories/x.md", "src/app.ts"]);
17
+ });
18
+ it("can narrow commit to an explicit path set", () => {
19
+ const porcelain = [" M ui/plans/a.md", " M src/app.ts"].join("\n");
20
+ assert.deepEqual(resolveCommitPaths(porcelain, ["ui/plans/a.md"]), ["ui/plans/a.md"]);
21
+ assert.deepEqual(resolveCommitPaths(porcelain), ["ui/plans/a.md", "src/app.ts"]);
22
+ });
23
+ it("recognizes internal commit paths", () => {
24
+ assert.equal(isChimpHandsInternalCommitPath("chimphands-run.log"), true);
25
+ assert.equal(isChimpHandsInternalCommitPath(".chimphands/tmp"), true);
26
+ assert.equal(isChimpHandsInternalCommitPath("ui/plans/x.md"), false);
27
+ });
28
+ });
@@ -1045,8 +1045,13 @@ export const TOOL_DEFINITIONS = [
1045
1045
  inputSchema: S.reportAgentActionInput,
1046
1046
  execute: async (args, { postMcp }) => {
1047
1047
  const a = args;
1048
- const actorRaw = (a.actorType ?? "local-agent").toString();
1049
- const actorType = actorRaw.toUpperCase().replace(/-/g, "_") === "CLOUD_AGENT" ? "CLOUD_AGENT" : "LOCAL_AGENT";
1048
+ const sessionId = (process.env.SESSION_ID ||
1049
+ process.env.CHIMPHANDS_SESSION_ID ||
1050
+ "").trim();
1051
+ const actorRaw = (a.actorType ?? (sessionId ? "cloud-agent" : "local-agent")).toString();
1052
+ const actorType = actorRaw.toUpperCase().replace(/-/g, "_") === "CLOUD_AGENT" || sessionId
1053
+ ? "CLOUD_AGENT"
1054
+ : "LOCAL_AGENT";
1050
1055
  const actionNorm = a.actionType.toString().toUpperCase().replace(/-/g, "_");
1051
1056
  let actionType = actionNorm;
1052
1057
  if (actionNorm === "COMPLETED" || actionNorm === "ACTION_COMPLETED") {
@@ -1062,6 +1067,8 @@ export const TOOL_DEFINITIONS = [
1062
1067
  actorType,
1063
1068
  entityType: a.entityType,
1064
1069
  };
1070
+ if (sessionId)
1071
+ body.chimphandsSessionId = sessionId;
1065
1072
  if (a.policyFile)
1066
1073
  body.policyFile = a.policyFile;
1067
1074
  if (a.policyVersion)
@@ -1071,8 +1078,10 @@ export const TOOL_DEFINITIONS = [
1071
1078
  body.gitSha = gitSha;
1072
1079
  if (a.userId)
1073
1080
  body.userId = a.userId;
1074
- else if (process.env.TESTCHIMP_USER_ID)
1081
+ else if (!sessionId && process.env.TESTCHIMP_USER_ID) {
1082
+ // ChimpHands: server resolves responsible user from SESSION_ID; do not stamp service account.
1075
1083
  body.userId = process.env.TESTCHIMP_USER_ID;
1084
+ }
1076
1085
  if (a.branchName)
1077
1086
  body.branchName = a.branchName;
1078
1087
  // Nested traceability wins for agentModel / skillVersion / cliVersion;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
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 dist/chimphands/agentBranch.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 dist/chimphands/worktreeCommitPaths.test.js",
17
17
  "watch": "tsc --watch",
18
18
  "start": "node dist/bin/testchimp.js",
19
19
  "prepublishOnly": "npm run build"