@testchimp/cli 0.1.76 → 0.1.77

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
@@ -1312,7 +1316,7 @@ async function restartLocalOpencodeServer(attachUrl) {
1312
1316
  const port = u.port || (u.protocol === "https:" ? "443" : "80");
1313
1317
  killListenersOnPort(port);
1314
1318
  await sleep(300);
1315
- const logFd = openSync("opencode-server.log", "a");
1319
+ const logFd = openSync(OPENCODE_SERVER_LOG_PATH, "a");
1316
1320
  const child = spawn("opencode", ["serve", "--port", port, "--hostname", hostname], {
1317
1321
  detached: true,
1318
1322
  stdio: ["ignore", logFd, logFd],
@@ -1327,6 +1331,7 @@ async function restartLocalOpencodeServer(attachUrl) {
1327
1331
  /* best effort */
1328
1332
  }
1329
1333
  }
1334
+ console.error(`ChimpHands opencode serve log: ${OPENCODE_SERVER_LOG_PATH}`);
1330
1335
  console.error(`ChimpHands restarted OpenCode serve on ${hostname}:${port} (pid ${child.pid ?? "?"})`);
1331
1336
  await waitForOpencodeHttp(attachUrl, 60_000);
1332
1337
  }
@@ -1627,8 +1632,13 @@ async function ackWorktreeFileWrite(backend, apiKey, sessionId, requestId, ok, e
1627
1632
  requestId,
1628
1633
  ok,
1629
1634
  };
1630
- if (errorMessage)
1631
- body.errorMessage = errorMessage.slice(0, 2000);
1635
+ if (errorMessage) {
1636
+ body.errorMessage = errorMessage
1637
+ .replace(/[\r\n\t]+/g, " ")
1638
+ .replace(/ +/g, " ")
1639
+ .trim()
1640
+ .slice(0, 2000);
1641
+ }
1632
1642
  await postJson(backend, apiKey, "/api/chimphands/ack_worktree_file_write", body).catch((err) => {
1633
1643
  console.error(`ChimpHands ack_worktree_file_write failed: ${err instanceof Error ? err.message : String(err)}`);
1634
1644
  });
@@ -1651,7 +1661,9 @@ async function applyUserFileEdit(backend, apiKey, sessionId, edit, turnActive) {
1651
1661
  await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, true);
1652
1662
  return;
1653
1663
  }
1654
- const pushed = await commitAndPushDirtyWorktree("User Update");
1664
+ const pushed = await commitAndPushDirtyWorktree("User Update", {
1665
+ paths: [relative],
1666
+ });
1655
1667
  if (!pushed.ok) {
1656
1668
  await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, false, pushed.error || "commit/push failed");
1657
1669
  return;
@@ -2509,41 +2521,15 @@ function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
2509
2521
  stopped = true;
2510
2522
  };
2511
2523
  }
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) {
2524
+ /**
2525
+ * Commit+push product changes on the session agent branch.
2526
+ *
2527
+ * Policy: allowlist staging from `git status` (never `git add -A` / exclude pathspecs).
2528
+ * Runner scaffolding is filtered out; anything already staged by the agent that is
2529
+ * scaffolding is unstaged before commit. Session logs are written under os.tmpdir(),
2530
+ * not the worktree.
2531
+ */
2532
+ async function commitAndPushDirtyWorktree(message, opts) {
2547
2533
  const run = (args, env) => runGit(args, env);
2548
2534
  try {
2549
2535
  const branch = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
@@ -2567,35 +2553,16 @@ async function commitAndPushDirtyWorktree(message) {
2567
2553
  console.error(`ChimpHands git status failed: ${error}`);
2568
2554
  return { ok: false, error };
2569
2555
  }
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 };
2556
+ const toStage = resolveCommitPaths(status.out, opts?.paths);
2557
+ if (toStage.length) {
2558
+ const add = await run(["add", "--", ...toStage]);
2559
+ if (add.code !== 0) {
2560
+ const error = add.err || add.out || "git add failed";
2561
+ console.error(`ChimpHands git add failed: ${error}`);
2562
+ return { ok: false, error };
2563
+ }
2584
2564
  }
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
- ]);
2565
+ // Drop scaffolding the agent may have staged (git add -A, etc.).
2599
2566
  const staged = await run(["diff", "--cached", "--name-only"]);
2600
2567
  if (staged.code !== 0) {
2601
2568
  const error = staged.err || staged.out || "git diff --cached failed";
@@ -2611,7 +2578,13 @@ async function commitAndPushDirtyWorktree(message) {
2611
2578
  await run(["reset", "-q", "HEAD", "--", ...internalStaged]);
2612
2579
  }
2613
2580
  const keepStaged = stagedPaths.filter((p) => !isChimpHandsInternalCommitPath(p));
2614
- if (!keepStaged.length) {
2581
+ const pathScoped = !!opts?.paths?.length;
2582
+ if (pathScoped) {
2583
+ if (!toStage.length) {
2584
+ return { ok: true };
2585
+ }
2586
+ }
2587
+ else if (!keepStaged.length) {
2615
2588
  return { ok: true };
2616
2589
  }
2617
2590
  const commitEnv = {
@@ -2620,7 +2593,30 @@ async function commitAndPushDirtyWorktree(message) {
2620
2593
  GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || "ChimpHands",
2621
2594
  GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || "chimphands@testchimp.io",
2622
2595
  };
2623
- const commit = await run(["-c", "user.name=ChimpHands", "-c", "user.email=chimphands@testchimp.io", "commit", "-m", message], commitEnv);
2596
+ // Pathspec form is `git commit --only` — commits just those paths even if
2597
+ // the index has other staged product files.
2598
+ const commitArgs = pathScoped
2599
+ ? [
2600
+ "-c",
2601
+ "user.name=ChimpHands",
2602
+ "-c",
2603
+ "user.email=chimphands@testchimp.io",
2604
+ "commit",
2605
+ "-m",
2606
+ message,
2607
+ "--",
2608
+ ...toStage,
2609
+ ]
2610
+ : [
2611
+ "-c",
2612
+ "user.name=ChimpHands",
2613
+ "-c",
2614
+ "user.email=chimphands@testchimp.io",
2615
+ "commit",
2616
+ "-m",
2617
+ message,
2618
+ ];
2619
+ const commit = await run(commitArgs, commitEnv);
2624
2620
  if (commit.code !== 0) {
2625
2621
  const error = commit.err || commit.out || "git commit failed";
2626
2622
  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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.76",
3
+ "version": "0.1.77",
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"