claude-teammate 0.1.313 → 0.1.315

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-teammate",
3
- "version": "0.1.313",
3
+ "version": "0.1.315",
4
4
  "description": "CLI bootstrapper for Claude Teammate.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,6 +23,16 @@ function formatPriorWorkContext(input) {
23
23
  return `${sections.join("\n")}\n`;
24
24
  }
25
25
 
26
+ // Pre-baked code-graph grounding (symbols + call paths) injected by the worker.
27
+ // Empty when codegraph is unavailable, so prompts are unchanged in that case.
28
+ function formatCodegraphContext(input) {
29
+ const ctx = String(input.codegraphContext || "").trim();
30
+ if (!ctx) {
31
+ return "";
32
+ }
33
+ return `\nCode graph context (symbols + call paths relevant to this task — use it to locate code precisely and avoid hallucinating APIs that do not exist in this repository):\n${ctx}\n`;
34
+ }
35
+
26
36
  const INCLUDE_RESOURCE_LINKS_RULE =
27
37
  "When your response is posted as a comment on a Jira issue, GitHub issue, or GitHub PR, include the URL of every resource you created (e.g. Jira task, spreadsheet, test design, test cases, document, Confluence page) so the reader can navigate directly to it.";
28
38
 
@@ -400,7 +410,7 @@ ${input.latestComment?.body || "(none)"}
400
410
 
401
411
  Recent PR comments:
402
412
  ${recentComments || "(none)"}
403
- ${formatPriorWorkContext(input)}
413
+ ${formatPriorWorkContext(input)}${formatCodegraphContext(input)}
404
414
  Instructions:
405
415
  - Checkout the branch above in this repository.
406
416
  - Implement the plan described in the pull request body.
@@ -522,7 +532,7 @@ ${JSON.stringify(input.memory?.epic || {}, null, 2)}
522
532
 
523
533
  Step to implement:
524
534
  ${input.step}
525
- ${formatPriorWorkContext(input)}
535
+ ${formatPriorWorkContext(input)}${formatCodegraphContext(input)}
526
536
  Instructions:
527
537
  - Checkout the branch above in this repository.
528
538
  - Implement only the step described above. Do not implement other parts of the plan.
package/src/repo.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { access, mkdir, rename, rm } from "node:fs/promises";
2
+ import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { buildGitEnvForRepoUrl, parseGitHubRepoUrl, parseRepoUrl } from "./forge/repo-host.js";
@@ -24,6 +24,38 @@ export class RepoCheckoutNotReadyError extends Error {
24
24
  }
25
25
  }
26
26
 
27
+ // Idempotently add ignore patterns to this checkout's local exclude file
28
+ // (`.git/info/exclude`). Worktree-safe: resolves the shared common git dir so a
29
+ // single exclude file covers every worktree. Used to keep tooling artifacts
30
+ // (e.g. `.codegraph/`) out of `git add -A` and `git clean -fd` without touching
31
+ // the repository's tracked `.gitignore`.
32
+ export async function ensureGitExcludes(repoPath, patterns) {
33
+ let commonDir;
34
+ try {
35
+ commonDir = (await execGitOutput(repoPath, ["rev-parse", "--git-common-dir"])).trim();
36
+ } catch {
37
+ return; // not a git checkout yet — nothing to exclude
38
+ }
39
+ const excludePath = path.isAbsolute(commonDir)
40
+ ? path.join(commonDir, "info", "exclude")
41
+ : path.join(repoPath, commonDir, "info", "exclude");
42
+
43
+ let current = "";
44
+ try {
45
+ current = await readFile(excludePath, "utf8");
46
+ } catch {
47
+ // exclude file may not exist yet — treated as empty
48
+ }
49
+ const present = new Set(current.split("\n").map((line) => line.trim()));
50
+ const missing = patterns.filter((pattern) => !present.has(pattern));
51
+ if (missing.length === 0) {
52
+ return; // idempotent: all patterns already excluded
53
+ }
54
+ const next = `${current.replace(/\n*$/u, "")}\n${missing.join("\n")}\n`;
55
+ await mkdir(path.dirname(excludePath), { recursive: true });
56
+ await writeFile(excludePath, next, "utf8");
57
+ }
58
+
27
59
  export async function ensureLocalRepo(repoUrl, reposDir) {
28
60
  const repo = parseRepoUrl(repoUrl);
29
61
  const checkoutPath = path.join(reposDir, repo.owner, repo.name);
@@ -247,7 +279,9 @@ export async function prepareRepoForDefaultBranch(repoPath) {
247
279
 
248
280
  export async function commitAndPushRepoChanges(repoPath, branchName, commitMessage, baseRef) {
249
281
  await execGit(repoPath, ["checkout", branchName]);
250
- await execGit(repoPath, ["add", "-A"]);
282
+ // Defense-in-depth: never stage the codegraph index even if the local
283
+ // exclude file was not applied to this checkout.
284
+ await execGit(repoPath, ["add", "-A", "--", ".", ":(exclude).codegraph"]);
251
285
  const statusOutput = await execGitOutput(repoPath, ["status", "--porcelain", "--ignore-submodules=dirty"]);
252
286
  if (!statusOutput.trim()) {
253
287
  const branchHasImplementation = await branchHasChangesAgainstMain(repoPath, baseRef);
@@ -770,6 +804,31 @@ export async function branchHasChangesAgainstMain(repoPath, baseRef) {
770
804
  return Number.isFinite(count) && count > 0;
771
805
  }
772
806
 
807
+ /**
808
+ * True when HEAD's own work has already landed on the base branch (e.g. the
809
+ * branch was merged via another MR / a human merged it directly). In that case
810
+ * HEAD is an ancestor of the base tip while still being a distinct commit, so a
811
+ * branch-vs-base diff is empty and re-running Claude can never produce changes.
812
+ * Callers use this to terminate instead of looping on "no changes to commit".
813
+ * A brand-new branch that merely points at the base tip (HEAD === base) returns
814
+ * false — that is an unstarted PR, not a merged one. Best-effort; never throws.
815
+ */
816
+ export async function branchMergedIntoBase(repoPath, baseRef) {
817
+ const branch = baseRef || (await resolveRemoteDefaultBranch(repoPath));
818
+ try {
819
+ const headSha = (await execGitOutput(repoPath, ["rev-parse", "HEAD"])).trim();
820
+ const baseSha = (await execGitOutput(repoPath, ["rev-parse", `origin/${branch}`])).trim();
821
+ if (!headSha || !baseSha || headSha === baseSha) {
822
+ return false;
823
+ }
824
+ // exit 0 → HEAD is an ancestor of the base tip → already merged.
825
+ await execGit(repoPath, ["merge-base", "--is-ancestor", "HEAD", `origin/${branch}`]);
826
+ return true;
827
+ } catch {
828
+ return false;
829
+ }
830
+ }
831
+
773
832
  async function resolveRemoteDefaultBranch(repoPath) {
774
833
  try {
775
834
  const output = await execGitOutput(repoPath, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
@@ -0,0 +1,58 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ // Resolve binary: env override wins, otherwise rely on PATH (with the
9
+ // user-local install dir prepended in runCg's env).
10
+ function codegraphBin() {
11
+ return process.env.CODEGRAPH_BIN || "codegraph";
12
+ }
13
+
14
+ const INDEX_TIMEOUT_MS = 90_000;
15
+ const EXPLORE_TIMEOUT_MS = 30_000;
16
+ const CONTEXT_MAX_CHARS = 4000;
17
+
18
+ async function runCg(args, { cwd, timeout, logger }) {
19
+ try {
20
+ const { stdout } = await execFileAsync(codegraphBin(), args, {
21
+ cwd,
22
+ timeout,
23
+ env: { ...process.env, PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}` }
24
+ });
25
+ return stdout || "";
26
+ } catch (error) {
27
+ // Codegraph failure must never block the task (mirrors the Claude-CLI rule).
28
+ await logger?.error?.("codegraph command failed; continuing without graph context", {
29
+ command: args[0],
30
+ cwd,
31
+ error: error?.message || String(error)
32
+ });
33
+ return null;
34
+ }
35
+ }
36
+
37
+ // init on first use for this checkout, incremental sync afterwards. Never throws.
38
+ export async function ensureCodegraphIndex(repoPath, logger) {
39
+ const indexed = existsSync(path.join(repoPath, ".codegraph"));
40
+ const args = indexed ? ["sync"] : ["init", "--force"];
41
+ await runCg(args, { cwd: repoPath, timeout: INDEX_TIMEOUT_MS, logger });
42
+ }
43
+
44
+ // Returns a grounding string (or "" when unavailable). Never throws.
45
+ export async function exploreCodegraph(repoPath, query, logger) {
46
+ if (!query?.trim()) {
47
+ return "";
48
+ }
49
+ const out = await runCg(["explore", query.trim()], {
50
+ cwd: repoPath,
51
+ timeout: EXPLORE_TIMEOUT_MS,
52
+ logger
53
+ });
54
+ if (!out) {
55
+ return "";
56
+ }
57
+ return out.length > CONTEXT_MAX_CHARS ? `${out.slice(0, CONTEXT_MAX_CHARS)}\n[codegraph context truncated]` : out;
58
+ }
@@ -6,12 +6,15 @@ import {
6
6
  } from "../claude.js";
7
7
  import {
8
8
  branchHasChangesAgainstMain,
9
+ branchMergedIntoBase,
9
10
  commitAndPushRepoChanges,
11
+ ensureGitExcludes,
10
12
  ensureWorktreeForBranch,
11
13
  getBranchDiffAgainstBase,
12
14
  prepareRepoForBranch,
13
15
  removeWorktree
14
16
  } from "../repo.js";
17
+ import { ensureCodegraphIndex, exploreCodegraph } from "./codegraph-context.js";
15
18
  import { buildEpicMemoryPromptSnapshot, persistEpicImplementationKnowledge } from "./epic-memory.js";
16
19
  import {
17
20
  clearForgePullRequestBlocked,
@@ -189,6 +192,21 @@ export async function processPullRequestImplementation({
189
192
  const issueKey = githubIssueMemory?.jira_key || `PR-${detail.number}`;
190
193
  const epicSnapshot = buildEpicMemoryPromptSnapshot(epicMemory);
191
194
 
195
+ // Codegraph: keep the index local-only (never staged/pushed/cleaned), then
196
+ // (re)index every accessible checkout and pre-bake grounding context for the
197
+ // implementation prompt. All steps fail soft — codegraph is never required.
198
+ const codegraphRepoPaths =
199
+ Array.isArray(repoAccess.repoPaths) && repoAccess.repoPaths.length > 0 ? repoAccess.repoPaths : [repoPath];
200
+ for (const cgRepoPath of codegraphRepoPaths) {
201
+ await ensureGitExcludes(cgRepoPath, [".codegraph/"]);
202
+ await ensureCodegraphIndex(cgRepoPath, logger);
203
+ }
204
+ const codegraphContext = await exploreCodegraph(
205
+ repoPath,
206
+ `${issueKey} ${detail.title} ${stripImplementationPlan(nextBody).slice(0, 500)}`,
207
+ logger
208
+ );
209
+
192
210
  // Resume the prior Claude conversation for this PR branch so a follow-up
193
211
  // comment / reopen keeps the model's earlier context instead of rebuilding
194
212
  // it from memory (which dropped diffs/decisions and caused re-work). The
@@ -284,6 +302,7 @@ export async function processPullRequestImplementation({
284
302
  result = await services.runClaudeImplementationStep({
285
303
  pullRequest: { ...detail, body: nextBody },
286
304
  step: stepText,
305
+ codegraphContext,
287
306
  memory: { epic: epicSnapshot },
288
307
  epicContext: epicMemory,
289
308
  branchDiff: stepBranchDiff,
@@ -331,6 +350,16 @@ export async function processPullRequestImplementation({
331
350
  pr: detail.number
332
351
  });
333
352
  result = { result: "implemented", summary: "Implementation already committed on previous run." };
353
+ } else if (!latestComment && (await branchMergedIntoBase(repoPath, detail.baseRef).catch(() => false))) {
354
+ // The branch's work already landed on the base (merged via another MR or
355
+ // by a human). There is nothing left to implement, so skip Claude
356
+ // entirely — running it would only report "no changes" and loop. A human
357
+ // comment overrides this so feedback can still be addressed.
358
+ await logger.info("Head branch already merged into base; skipping Claude (nothing to implement)", {
359
+ repo: repo.url,
360
+ pr: detail.number
361
+ });
362
+ result = { result: "implemented", summary: "Changes already merged into the base branch." };
334
363
  } else {
335
364
  result = await services.runClaudeImplementation({
336
365
  pullRequest: { ...detail, body: nextBody },
@@ -338,6 +367,7 @@ export async function processPullRequestImplementation({
338
367
  epicContext: epicMemory,
339
368
  latestComment,
340
369
  branchDiff,
370
+ codegraphContext,
341
371
  repoPath,
342
372
  repoPaths: repoAccess.repoPaths,
343
373
  branchName: detail.headRef,
@@ -440,7 +470,53 @@ export async function processPullRequestImplementation({
440
470
  );
441
471
 
442
472
  if (!commitResult.committed && !commitResult.branchHasImplementation) {
443
- throw new Error("Claude reported success but no repository changes were available to commit.");
473
+ // No diff to commit. If the branch's work already landed on the base
474
+ // (merged via another MR / by a human), re-running Claude can never
475
+ // produce changes — treat it as terminally implemented instead of
476
+ // looping. Otherwise stop without preserving STUCK_AT so the bot waits
477
+ // for human guidance rather than machine-gun retrying an unchangeable
478
+ // no-op every retry window.
479
+ const mergedIntoBase = await branchMergedIntoBase(repoPath, detail.baseRef).catch(() => false);
480
+ if (mergedIntoBase) {
481
+ const mergedBody = setPullRequestStatus(nextBody, "IMPLEMENTED");
482
+ await github.updatePullRequest(repo.url, detail.number, { title: detail.title, body: mergedBody });
483
+ await services.clearForgePullRequestBlocked(github, { ...detail, repoUrl: repo.url });
484
+ if (markReadyOnSuccess) {
485
+ await github.markPullRequestReady(repo.url, detail.number);
486
+ await services.transitionLinkedJiraIssueToReview(detail.title, jira, logger, detail.number);
487
+ }
488
+ const mergedComment =
489
+ successCommentBody ||
490
+ formatPullRequestOutcomeComment(
491
+ result.summary,
492
+ "The requested changes are already merged into the base branch; nothing left to implement."
493
+ );
494
+ await postPullRequestComment(github, repo.url, detail.number, mergedComment);
495
+ await postLinkedJiraComment(extractJiraKey(detail.title), mergedComment, jira, logger);
496
+ await logger.info("Branch already merged into base; PR marked IMPLEMENTED", {
497
+ repo: repo.url,
498
+ pr: detail.number
499
+ });
500
+ return buildDraftPrState(
501
+ { ...detail, body: mergedBody, draft: markReadyOnSuccess ? false : detail.draft },
502
+ "IMPLEMENTED",
503
+ "implemented"
504
+ );
505
+ }
506
+ const noChangeBody = clearStuckAt(setPullRequestStatus(nextBody, "STUCK"));
507
+ await github.updatePullRequest(repo.url, detail.number, { title: detail.title, body: noChangeBody });
508
+ await services.setForgePullRequestBlocked(github, { ...detail, repoUrl: repo.url });
509
+ const noChangeComment = formatPullRequestOutcomeComment(
510
+ result.summary,
511
+ "Claude reported success but produced no repository changes, so there was nothing to commit. Halting automatic retries until there is new guidance."
512
+ );
513
+ await postPullRequestComment(github, repo.url, detail.number, noChangeComment);
514
+ await postLinkedJiraComment(extractJiraKey(detail.title), noChangeComment, jira, logger);
515
+ await logger.error("Pull request produced no repository changes; halting auto-retry", {
516
+ repo: repo.url,
517
+ pr: detail.number
518
+ });
519
+ return buildDraftPrState({ ...detail, body: noChangeBody }, "STUCK", "stuck");
444
520
  }
445
521
 
446
522
  const implementedBody = setPullRequestStatus(nextBody, "IMPLEMENTED");