@testchimp/cli 0.1.73 → 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
@@ -1048,8 +1052,10 @@ function isAssistantEchoOfSentPrompt(assistant, ...sentPrompts) {
1048
1052
  }
1049
1053
  return false;
1050
1054
  }
1055
+ /** First line of host-injected OpenCode prompts — UI filters on this marker. */
1056
+ const HOST_PROMPT_WRAPPER_MARKER = "[[[chimphands.host_prompt.v1]]]";
1051
1057
  function wrapPromptWithContext(conversationSummary, userPrompt, isNewOpencodeSession, workingBranch, pullRequestUrl, baseBranch) {
1052
- const parts = [];
1058
+ const parts = [HOST_PROMPT_WRAPPER_MARKER];
1053
1059
  if (baseBranch?.trim()) {
1054
1060
  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.", "");
1055
1061
  }
@@ -1062,11 +1068,11 @@ function wrapPromptWithContext(conversationSummary, userPrompt, isNewOpencodeSes
1062
1068
  // user message and the UI shows host control text in chat.
1063
1069
  if (isNewOpencodeSession && conversationSummary.trim()) {
1064
1070
  parts.push(`Conversation so far:\n${conversationSummary.trim()}`, "", `Current task:\n${task}`);
1065
- return parts.filter(Boolean).join("\n");
1071
+ return parts.filter((p) => p !== "").join("\n");
1066
1072
  }
1067
- if (parts.length) {
1073
+ if (parts.length > 1) {
1068
1074
  parts.push(`Current task:\n${task}`);
1069
- return parts.filter(Boolean).join("\n");
1075
+ return parts.filter((p) => p !== "").join("\n");
1070
1076
  }
1071
1077
  return task;
1072
1078
  }
@@ -1310,7 +1316,7 @@ async function restartLocalOpencodeServer(attachUrl) {
1310
1316
  const port = u.port || (u.protocol === "https:" ? "443" : "80");
1311
1317
  killListenersOnPort(port);
1312
1318
  await sleep(300);
1313
- const logFd = openSync("opencode-server.log", "a");
1319
+ const logFd = openSync(OPENCODE_SERVER_LOG_PATH, "a");
1314
1320
  const child = spawn("opencode", ["serve", "--port", port, "--hostname", hostname], {
1315
1321
  detached: true,
1316
1322
  stdio: ["ignore", logFd, logFd],
@@ -1325,6 +1331,7 @@ async function restartLocalOpencodeServer(attachUrl) {
1325
1331
  /* best effort */
1326
1332
  }
1327
1333
  }
1334
+ console.error(`ChimpHands opencode serve log: ${OPENCODE_SERVER_LOG_PATH}`);
1328
1335
  console.error(`ChimpHands restarted OpenCode serve on ${hostname}:${port} (pid ${child.pid ?? "?"})`);
1329
1336
  await waitForOpencodeHttp(attachUrl, 60_000);
1330
1337
  }
@@ -1625,8 +1632,13 @@ async function ackWorktreeFileWrite(backend, apiKey, sessionId, requestId, ok, e
1625
1632
  requestId,
1626
1633
  ok,
1627
1634
  };
1628
- if (errorMessage)
1629
- 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
+ }
1630
1642
  await postJson(backend, apiKey, "/api/chimphands/ack_worktree_file_write", body).catch((err) => {
1631
1643
  console.error(`ChimpHands ack_worktree_file_write failed: ${err instanceof Error ? err.message : String(err)}`);
1632
1644
  });
@@ -1649,7 +1661,9 @@ async function applyUserFileEdit(backend, apiKey, sessionId, edit, turnActive) {
1649
1661
  await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, true);
1650
1662
  return;
1651
1663
  }
1652
- const pushed = await commitAndPushDirtyWorktree("User Update");
1664
+ const pushed = await commitAndPushDirtyWorktree("User Update", {
1665
+ paths: [relative],
1666
+ });
1653
1667
  if (!pushed.ok) {
1654
1668
  await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, false, pushed.error || "commit/push failed");
1655
1669
  return;
@@ -2387,10 +2401,20 @@ export async function runChimphands(opts) {
2387
2401
  if (result.opencodeSessionId) {
2388
2402
  opencodeSessionId = result.opencodeSessionId;
2389
2403
  }
2390
- // Turn-end durable reconcile into PG (idempotent messageIds). Mid-turn was
2391
- // ephemeral-only when UI attached; async runs get their transcript here.
2392
- // Also reconcile on failure so partial assistant/tool output is not lost.
2393
- if (attachUrl && opencodeSessionId) {
2404
+ // Checkpoint dirty worktree before unlocking the UI so Files (GitHub tip
2405
+ // of workingBranch) matches what the user sees when WAITING_USER lands.
2406
+ await commitAndPushDirtyWorktree("chimphands: commit after turn");
2407
+ if (attachUrl && opencodeSessionId && midTurnInjectedById.size) {
2408
+ try {
2409
+ await releaseUnansweredMidTurnMessages(attachUrl, opencodeSessionId, midTurnInjectedById, queue, wakePromptWaiter, midTurnSidecarAnswered);
2410
+ }
2411
+ catch (err) {
2412
+ console.error(`ChimpHands mid-turn steering release failed: ${err instanceof Error ? err.message : String(err)}`);
2413
+ }
2414
+ }
2415
+ const reconcileTurn = async () => {
2416
+ if (!attachUrl || !opencodeSessionId)
2417
+ return;
2394
2418
  try {
2395
2419
  await reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, (role, content, opts) => {
2396
2420
  turnPostEvent(role, content, {
@@ -2404,18 +2428,7 @@ export async function runChimphands(opts) {
2404
2428
  catch (err) {
2405
2429
  console.error(`ChimpHands turn-end reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
2406
2430
  }
2407
- }
2408
- // Always checkpoint dirty worktree after each turn so PR + Files pane
2409
- // (GitHub compare) stay current whether or not a browser UI is attached.
2410
- await commitAndPushDirtyWorktree("chimphands: commit after turn");
2411
- if (attachUrl && opencodeSessionId && midTurnInjectedById.size) {
2412
- try {
2413
- await releaseUnansweredMidTurnMessages(attachUrl, opencodeSessionId, midTurnInjectedById, queue, wakePromptWaiter, midTurnSidecarAnswered);
2414
- }
2415
- catch (err) {
2416
- console.error(`ChimpHands mid-turn steering release failed: ${err instanceof Error ? err.message : String(err)}`);
2417
- }
2418
- }
2431
+ };
2419
2432
  if (result.code !== 0 && !result.cancelled) {
2420
2433
  const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
2421
2434
  console.error(`ChimpHands OpenCode failed: ${errMsg}`);
@@ -2437,16 +2450,25 @@ export async function runChimphands(opts) {
2437
2450
  postEvent(ROLE_STATUS, errMsg, { status: STATUS_FAILED });
2438
2451
  await complete(STATUS_FAILED, errMsg);
2439
2452
  }
2453
+ // Persist partial transcript even on failure (after push / status report).
2454
+ await reconcileTurn();
2440
2455
  exitCode = result.code || 1;
2441
2456
  break;
2442
2457
  }
2458
+ const waitingOpts = {
2459
+ status: STATUS_WAITING_USER,
2460
+ ...(workingBranch ? { workingBranch } : {}),
2461
+ ...(pullRequestUrl ? { pullRequestUrl } : {}),
2462
+ };
2443
2463
  if (result.cancelled) {
2444
- postEvent(ROLE_STATUS, "Turn stopped", { status: STATUS_WAITING_USER });
2464
+ postEvent(ROLE_STATUS, "Turn stopped", waitingOpts);
2445
2465
  }
2446
2466
  else {
2447
- postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
2467
+ postEvent(ROLE_STATUS, "Waiting for user input", waitingOpts);
2448
2468
  }
2449
2469
  await poster.flush();
2470
+ // Durable reconcile after unlock so Interrupt→Send is not blocked on PG writes.
2471
+ await reconcileTurn();
2450
2472
  lastUserActivity = Date.now();
2451
2473
  idle = false;
2452
2474
  prompt = (await waitForNextPrompt()) || "";
@@ -2499,8 +2521,15 @@ function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
2499
2521
  stopped = true;
2500
2522
  };
2501
2523
  }
2502
- /** Commit+push dirty worktree on the session agent branch before idle/teardown. */
2503
- 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) {
2504
2533
  const run = (args, env) => runGit(args, env);
2505
2534
  try {
2506
2535
  const branch = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
@@ -2524,22 +2553,70 @@ async function commitAndPushDirtyWorktree(message) {
2524
2553
  console.error(`ChimpHands git status failed: ${error}`);
2525
2554
  return { ok: false, error };
2526
2555
  }
2527
- if (!status.out.trim()) {
2528
- return { ok: true };
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
+ }
2529
2564
  }
2530
- const add = await run(["add", "-A"]);
2531
- if (add.code !== 0) {
2532
- const error = add.err || add.out || "git add failed";
2533
- console.error(`ChimpHands git add failed: ${error}`);
2565
+ // Drop scaffolding the agent may have staged (git add -A, etc.).
2566
+ const staged = await run(["diff", "--cached", "--name-only"]);
2567
+ if (staged.code !== 0) {
2568
+ const error = staged.err || staged.out || "git diff --cached failed";
2569
+ console.error(`ChimpHands git staged check failed: ${error}`);
2534
2570
  return { ok: false, error };
2535
2571
  }
2572
+ const stagedPaths = staged.out
2573
+ .split("\n")
2574
+ .map((line) => line.trim())
2575
+ .filter(Boolean);
2576
+ const internalStaged = stagedPaths.filter(isChimpHandsInternalCommitPath);
2577
+ if (internalStaged.length) {
2578
+ await run(["reset", "-q", "HEAD", "--", ...internalStaged]);
2579
+ }
2580
+ const keepStaged = stagedPaths.filter((p) => !isChimpHandsInternalCommitPath(p));
2581
+ const pathScoped = !!opts?.paths?.length;
2582
+ if (pathScoped) {
2583
+ if (!toStage.length) {
2584
+ return { ok: true };
2585
+ }
2586
+ }
2587
+ else if (!keepStaged.length) {
2588
+ return { ok: true };
2589
+ }
2536
2590
  const commitEnv = {
2537
2591
  GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || "ChimpHands",
2538
2592
  GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || "chimphands@testchimp.io",
2539
2593
  GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || "ChimpHands",
2540
2594
  GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || "chimphands@testchimp.io",
2541
2595
  };
2542
- 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);
2543
2620
  if (commit.code !== 0) {
2544
2621
  const error = commit.err || commit.out || "git commit failed";
2545
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.73",
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"