claude-teammate 0.1.306 → 0.1.307

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.306",
3
+ "version": "0.1.307",
4
4
  "description": "CLI bootstrapper for Claude Teammate.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -66,6 +66,25 @@ export function getProviderLabel(provider) {
66
66
  return provider === "gitlab" ? "GitLab" : "GitHub";
67
67
  }
68
68
 
69
+ // Turn a host into an env-var suffix, e.g. gitlab.ignify.co -> GITLAB_IGNIFY_CO,
70
+ // so a self-hosted instance can carry its own token / CA bundle independent of
71
+ // the global one (fixes "token is not configured for <host>" and TLS failures
72
+ // against self-hosted GitLab).
73
+ function hostEnvSlug(host) {
74
+ return String(host || "")
75
+ .trim()
76
+ .toUpperCase()
77
+ .replace(/[^A-Z0-9]+/gu, "_")
78
+ .replace(/^_+|_+$/gu, "");
79
+ }
80
+
81
+ // Resolve a config value preferring a per-host override over the global key.
82
+ function resolveHostScoped(config, baseKey, hostSlug) {
83
+ const scoped = hostSlug ? config[`${baseKey}_${hostSlug}`] || process.env[`${baseKey}_${hostSlug}`] : "";
84
+ const global = config[baseKey] || process.env[baseKey] || "";
85
+ return String(scoped || global || "").trim();
86
+ }
87
+
69
88
  export function buildGitEnvForRepoUrl(targetUrl = "https://github.com/", config = process.env) {
70
89
  const env = {
71
90
  ...process.env,
@@ -84,24 +103,35 @@ export function buildGitEnvForRepoUrl(targetUrl = "https://github.com/", config
84
103
  return env;
85
104
  }
86
105
 
87
- const token = String(
88
- repo.provider === "gitlab"
89
- ? config.GITLAB_TOKEN || process.env.GITLAB_TOKEN || ""
90
- : config.GITHUB_PAT || process.env.GITHUB_PAT || ""
91
- ).trim();
106
+ const hostSlug = hostEnvSlug(repo.host);
107
+ const tokenBaseKey = repo.provider === "gitlab" ? "GITLAB_TOKEN" : "GITHUB_PAT";
108
+ const token = resolveHostScoped(config, tokenBaseKey, hostSlug);
109
+
110
+ // CA bundle for self-hosted hosts behind a private/internal CA. Per-host
111
+ // override (GIT_SSL_CAINFO_<HOST>) wins over the global GIT_SSL_CAINFO.
112
+ const caBundle = resolveHostScoped(config, "GIT_SSL_CAINFO", hostSlug);
92
113
 
93
- if (!token) {
114
+ const configEntries = [];
115
+ if (token) {
116
+ const username = repo.provider === "gitlab" ? "oauth2" : "x-access-token";
117
+ const basicAuth = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
118
+ configEntries.push([`http.${repo.origin}/.extraheader`, `AUTHORIZATION: Basic ${basicAuth}`]);
119
+ }
120
+ if (caBundle) {
121
+ configEntries.push([`http.${repo.origin}/.sslCAInfo`, caBundle]);
122
+ env.GIT_SSL_CAINFO = caBundle;
123
+ }
124
+
125
+ if (configEntries.length === 0) {
94
126
  return env;
95
127
  }
96
128
 
97
- const username = repo.provider === "gitlab" ? "oauth2" : "x-access-token";
98
- const basicAuth = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
99
- return {
100
- ...env,
101
- GIT_CONFIG_COUNT: "1",
102
- GIT_CONFIG_KEY_0: `http.${repo.origin}/.extraheader`,
103
- GIT_CONFIG_VALUE_0: `AUTHORIZATION: Basic ${basicAuth}`
104
- };
129
+ const next = { ...env, GIT_CONFIG_COUNT: String(configEntries.length) };
130
+ configEntries.forEach(([key, value], i) => {
131
+ next[`GIT_CONFIG_KEY_${i}`] = key;
132
+ next[`GIT_CONFIG_VALUE_${i}`] = value;
133
+ });
134
+ return next;
105
135
  }
106
136
 
107
137
  export function extractRepoUrls(text) {
@@ -60,6 +60,33 @@ export function getLatestGitHubComment(comments) {
60
60
  return Array.isArray(comments) && comments.length > 0 ? comments[comments.length - 1] : null;
61
61
  }
62
62
 
63
+ // Post a one-shot "automatic retries paused" notice on a GitHub/GitLab issue
64
+ // when its circuit-breaker trips. Idempotent per CLAUDE.md: if the latest
65
+ // comment is already from the bot we skip, so the bot never stacks two
66
+ // consecutive comments across worker restarts / repeated trips.
67
+ export async function postGitHubBackoffNotice({ provider, botUser, repoUrl, issueNumber, count, logger }) {
68
+ try {
69
+ const detail = await provider.fetchIssue(repoUrl, issueNumber);
70
+ const latest = getLatestGitHubComment(detail.comments || []);
71
+ if (latest && isForgeBotAuthor(latest.author, botUser)) {
72
+ return false;
73
+ }
74
+ await provider.postIssueComment(
75
+ repoUrl,
76
+ issueNumber,
77
+ `This task has failed ${count} times in a row, so I have paused automatic retries to avoid spamming. Please check the issue details and repository setup, then add a comment when it is ready for me to try again.`
78
+ );
79
+ return true;
80
+ } catch (error) {
81
+ await logger?.error?.("Failed to post GitHub backoff notice (non-fatal)", {
82
+ repo: repoUrl,
83
+ issue: issueNumber,
84
+ error
85
+ });
86
+ return false;
87
+ }
88
+ }
89
+
63
90
  export async function markGitHubThreadRead({ github, logger, repoUrl, issueNumber, commentId = "" }) {
64
91
  try {
65
92
  if (!commentId) {
@@ -63,7 +63,7 @@ export function createDraftPullRequestPoller({
63
63
  return true;
64
64
  }
65
65
  if (stuckAt) {
66
- await logger.info("Draft PR is stuck but retry window has not elapsed, not queueing for work", {
66
+ await logger.debug("Draft PR is stuck but retry window has not elapsed, not queueing for work", {
67
67
  pr: pullRequest.number,
68
68
  repo: pullRequest.repoUrl
69
69
  });
@@ -83,7 +83,7 @@ export function createDraftPullRequestPoller({
83
83
  }
84
84
 
85
85
  if (hasEyesReaction(latestComment) || hasPlusOneReaction(latestComment)) {
86
- await logger.info("Draft PR latest comment already acknowledged, not queueing for work", {
86
+ await logger.debug("Draft PR latest comment already acknowledged, not queueing for work", {
87
87
  pr: pullRequest.number,
88
88
  repo: pullRequest.repoUrl
89
89
  });
@@ -92,7 +92,7 @@ export function createDraftPullRequestPoller({
92
92
 
93
93
  const isMember = await checkCommentAuthorIsMember(provider, repo, latestComment, logger, pullRequest.number, true);
94
94
  if (!isMember) {
95
- await logger.info("Draft PR latest comment author is not a repo member, not queueing for work", {
95
+ await logger.debug("Draft PR latest comment author is not a repo member, not queueing for work", {
96
96
  pr: pullRequest.number,
97
97
  repo: pullRequest.repoUrl
98
98
  });
@@ -100,7 +100,7 @@ export function createDraftPullRequestPoller({
100
100
  }
101
101
 
102
102
  if (isPullRequestReviewRequestComment(latestComment.body, botUser)) {
103
- await logger.info("Draft PR latest comment is only a review request, not queueing for work", {
103
+ await logger.debug("Draft PR latest comment is only a review request, not queueing for work", {
104
104
  pr: pullRequest.number,
105
105
  repo: pullRequest.repoUrl
106
106
  });
@@ -114,7 +114,7 @@ export function createDraftPullRequestPoller({
114
114
  const taskId = buildPullRequestQueueId(pullRequest.repoUrl, pullRequest.number);
115
115
 
116
116
  if (inFlight.has(taskId)) {
117
- await logger.info("Draft PR already queued or in-flight, skipping", {
117
+ await logger.debug("Draft PR already queued or in-flight, skipping", {
118
118
  pr: pullRequest.number,
119
119
  repo: pullRequest.repoUrl
120
120
  });
@@ -45,7 +45,7 @@ export function createJiraPoller({
45
45
 
46
46
  async function shouldDispatchIssue(issue) {
47
47
  if (hasPendingJiraWorkLabel(issue)) {
48
- await logger.info("Jira issue waiting on external input, not queueing for work", { issue: issue.key });
48
+ await logger.debug("Jira issue waiting on external input, not queueing for work", { issue: issue.key });
49
49
  return { dispatch: false, detail: null };
50
50
  }
51
51
 
@@ -70,7 +70,9 @@ export function createJiraPoller({
70
70
  }
71
71
  const shouldResume = hasNewHumanReplyWhileWaiting(detail, jiraBotUser, jira);
72
72
  if (!shouldResume) {
73
- await logger.info("Jira issue is in review with no new human reply, not queueing for work", { issue: issue.key });
73
+ await logger.debug("Jira issue is in review with no new human reply, not queueing for work", {
74
+ issue: issue.key
75
+ });
74
76
  }
75
77
  return { dispatch: shouldResume, detail: shouldResume ? detail : null };
76
78
  }
@@ -89,7 +91,7 @@ export function createJiraPoller({
89
91
  state.pollerCurrent.jira = String(issue?.key || "");
90
92
 
91
93
  if (inFlight.has(issue.key)) {
92
- await logger.info("Jira issue already queued or in-flight, skipping", { issue: issue.key });
94
+ await logger.debug("Jira issue already queued or in-flight, skipping", { issue: issue.key });
93
95
  continue;
94
96
  }
95
97
 
@@ -105,7 +107,7 @@ export function createJiraPoller({
105
107
  await persistState();
106
108
  }
107
109
  } else if (isIssueInBackoff(state, issue.key)) {
108
- await logger.info("Jira issue in failure backoff, skipping until retry window elapses", {
110
+ await logger.debug("Jira issue in failure backoff, skipping until retry window elapses", {
109
111
  issue: issue.key
110
112
  });
111
113
  continue;
@@ -68,7 +68,7 @@ export function createReviewPullRequestDiscussionPoller({
68
68
  for (const pr of prsWithThreads) {
69
69
  const taskId = buildTaskId(pr.repoUrl, pr.number);
70
70
  if (inFlight.has(taskId)) {
71
- await logger.info("Suggestion revision already in-flight, skipping", {
71
+ await logger.debug("Suggestion revision already in-flight, skipping", {
72
72
  pr: pr.number,
73
73
  repo: pr.repoUrl
74
74
  });
@@ -66,7 +66,7 @@ export function createReviewPullRequestPoller({
66
66
  const taskId = buildPullRequestQueueId(pr.repoUrl, pr.number);
67
67
 
68
68
  if (inFlight.has(taskId)) {
69
- await logger.info("PR review already queued or in-flight, skipping", {
69
+ await logger.debug("PR review already queued or in-flight, skipping", {
70
70
  pr: pr.number,
71
71
  repo: pr.repoUrl
72
72
  });
@@ -1,5 +1,10 @@
1
1
  import { listIssueMemoryRecords } from "../../memory.js";
2
- import { getForgeBotUserForRepo, getLatestGitHubComment, isForgeBotAuthor } from "../forge-sync.js";
2
+ import {
3
+ getForgeBotUserForRepo,
4
+ getLatestGitHubComment,
5
+ isForgeBotAuthor,
6
+ postGitHubBackoffNotice
7
+ } from "../forge-sync.js";
3
8
  import { clearIssueBackoff, isIssueInBackoff, recordIssueFailure } from "../issue-backoff.js";
4
9
  import { checkCommentAuthorIsMember, hasEyesReaction, hasPlusOneReaction, isApprovalComment } from "../pull-request.js";
5
10
  import { normalizeRepoIdentity } from "../repo-utils.js";
@@ -60,7 +65,7 @@ export function createTrackedIssuePoller({
60
65
  `${normalizeRepoIdentity(repo.url || "")}#${String(issue.number || "").trim()}`
61
66
  );
62
67
  if (!issueMemoryRecord) {
63
- await logger.info("GitHub issue has no linked Jira memory, not queueing for work", {
68
+ await logger.debug("GitHub issue has no linked Jira memory, not queueing for work", {
64
69
  issue: issue.number,
65
70
  repo: issue.repoUrl
66
71
  });
@@ -78,7 +83,7 @@ export function createTrackedIssuePoller({
78
83
  }
79
84
 
80
85
  if (hasEyesReaction(latestComment) || hasPlusOneReaction(latestComment)) {
81
- await logger.info("GitHub issue latest comment already acknowledged, not queueing for work", {
86
+ await logger.debug("GitHub issue latest comment already acknowledged, not queueing for work", {
82
87
  issue: issue.number,
83
88
  repo: issue.repoUrl
84
89
  });
@@ -94,7 +99,7 @@ export function createTrackedIssuePoller({
94
99
  linkedGitHubIssue &&
95
100
  String(linkedGitHubIssue.last_issue_memory_comment_id || "") === String(latestComment.id)
96
101
  ) {
97
- await logger.info("GitHub issue latest comment already processed (memory stamp), not queueing for work", {
102
+ await logger.debug("GitHub issue latest comment already processed (memory stamp), not queueing for work", {
98
103
  issue: issue.number,
99
104
  repo: issue.repoUrl
100
105
  });
@@ -103,7 +108,7 @@ export function createTrackedIssuePoller({
103
108
 
104
109
  const isMember = await checkCommentAuthorIsMember(provider, repo, latestComment, logger, issue.number, false);
105
110
  if (!isMember) {
106
- await logger.info("GitHub issue latest comment author is not a repo member, not queueing for work", {
111
+ await logger.debug("GitHub issue latest comment author is not a repo member, not queueing for work", {
107
112
  issue: issue.number,
108
113
  repo: issue.repoUrl
109
114
  });
@@ -147,7 +152,7 @@ export function createTrackedIssuePoller({
147
152
  const taskId = buildIssueQueueId(issue.repoUrl, issue.number);
148
153
 
149
154
  if (inFlight.has(taskId)) {
150
- await logger.info("GitHub issue already queued or in-flight, skipping", {
155
+ await logger.debug("GitHub issue already queued or in-flight, skipping", {
151
156
  issue: issue.number,
152
157
  repo: issue.repoUrl
153
158
  });
@@ -163,7 +168,7 @@ export function createTrackedIssuePoller({
163
168
  // human signal); otherwise skip while the issue is in its retry-backoff
164
169
  // window so a persistently failing issue is not re-dispatched every poll.
165
170
  if (isIssueInBackoff(state, taskId, { trigger: decision.commentId })) {
166
- await logger.info("GitHub issue in failure backoff, skipping until retry window elapses", {
171
+ await logger.debug("GitHub issue in failure backoff, skipping until retry window elapses", {
167
172
  issue: issue.number,
168
173
  repo: issue.repoUrl
169
174
  });
@@ -195,13 +200,23 @@ export function createTrackedIssuePoller({
195
200
  await processIssue({ issue, repo: repoWithPath, provider, botUser, taskId });
196
201
  clearIssueBackoff(state, taskId);
197
202
  } catch (error) {
198
- const { count } = recordIssueFailure(state, taskId, { trigger: decision.commentId });
203
+ const { count, shouldNotify } = recordIssueFailure(state, taskId, { trigger: decision.commentId });
199
204
  await logger.error("GitHub issue task failed", {
200
205
  issue: issue.number,
201
206
  repo: issue.repoUrl,
202
207
  error,
203
208
  failureCount: count
204
209
  });
210
+ if (shouldNotify) {
211
+ await postGitHubBackoffNotice({
212
+ provider,
213
+ botUser,
214
+ repoUrl: issue.repoUrl,
215
+ issueNumber: issue.number,
216
+ count,
217
+ logger
218
+ });
219
+ }
205
220
  } finally {
206
221
  inFlight.delete(taskId);
207
222
  removeWorkQueueItem(state, workQueueItem.id);
@@ -0,0 +1,70 @@
1
+ import { mkdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { atomicWriteFile } from "../fs-atomic.js";
4
+
5
+ // Durable map of Claude session ids keyed by repo + PR branch, so a PR comment
6
+ // or reopen can resume the prior conversation instead of rebuilding context
7
+ // from memory (which previously dropped diffs / decisions and caused the model
8
+ // to "forget" earlier work). Stored under the same memory dir as issue memory
9
+ // so it survives worker restarts.
10
+ const SESSIONS_FILE = "pr-sessions.json";
11
+ const MAX_ENTRIES = 1000;
12
+
13
+ function sessionStorePath(projectRoot) {
14
+ return path.join(projectRoot, "memory", SESSIONS_FILE);
15
+ }
16
+
17
+ function sessionKey(repoUrl, branchName) {
18
+ return `${String(repoUrl || "").trim()}#${String(branchName || "").trim()}`;
19
+ }
20
+
21
+ async function readStore(projectRoot) {
22
+ try {
23
+ const raw = await readFile(sessionStorePath(projectRoot), "utf8");
24
+ const parsed = JSON.parse(raw);
25
+ return parsed && typeof parsed === "object" ? parsed : {};
26
+ } catch {
27
+ // Missing file / corrupt JSON → start from empty; resume is best-effort.
28
+ return {};
29
+ }
30
+ }
31
+
32
+ export async function loadPrSessionId(projectRoot, repoUrl, branchName) {
33
+ if (!projectRoot || !branchName) {
34
+ return "";
35
+ }
36
+ const store = await readStore(projectRoot);
37
+ const entry = store[sessionKey(repoUrl, branchName)];
38
+ return entry && typeof entry.session_id === "string" ? entry.session_id : "";
39
+ }
40
+
41
+ // Serialize read-modify-write so concurrent PR tasks writing the shared file
42
+ // never lose each other's entries.
43
+ let writeChain = Promise.resolve();
44
+
45
+ export async function savePrSessionId(projectRoot, repoUrl, branchName, sessionId) {
46
+ if (!projectRoot || !branchName || !sessionId) {
47
+ return;
48
+ }
49
+ writeChain = writeChain
50
+ .then(async () => {
51
+ const store = await readStore(projectRoot);
52
+ store[sessionKey(repoUrl, branchName)] = {
53
+ session_id: String(sessionId).trim(),
54
+ updated_at: new Date().toISOString()
55
+ };
56
+ // Bound file growth: drop oldest entries beyond the cap.
57
+ const keys = Object.keys(store);
58
+ if (keys.length > MAX_ENTRIES) {
59
+ keys
60
+ .sort((a, b) => String(store[a].updated_at || "").localeCompare(String(store[b].updated_at || "")))
61
+ .slice(0, keys.length - MAX_ENTRIES)
62
+ .forEach((k) => delete store[k]);
63
+ }
64
+ const file = sessionStorePath(projectRoot);
65
+ await mkdir(path.dirname(file), { recursive: true });
66
+ await atomicWriteFile(file, JSON.stringify(store, null, 2));
67
+ })
68
+ .catch(() => {});
69
+ return writeChain;
70
+ }
@@ -20,6 +20,7 @@ import {
20
20
  setForgePullRequestBlocked,
21
21
  transitionLinkedJiraIssueToReview
22
22
  } from "./forge-sync.js";
23
+ import { loadPrSessionId, savePrSessionId } from "./pr-session-store.js";
23
24
  import {
24
25
  allPlanStepsDone,
25
26
  buildDraftPrState,
@@ -180,6 +181,16 @@ export async function processPullRequestImplementation({
180
181
  const issueKey = githubIssueMemory?.jira_key || `PR-${detail.number}`;
181
182
  const epicSnapshot = buildEpicMemoryPromptSnapshot(epicMemory);
182
183
 
184
+ // Resume the prior Claude conversation for this PR branch so a follow-up
185
+ // comment / reopen keeps the model's earlier context instead of rebuilding
186
+ // it from memory (which dropped diffs/decisions and caused re-work). The
187
+ // branchDiff + latestComment below remain as the fallback when no session
188
+ // id is stored or the stored id is stale.
189
+ const resumeSessionId = await loadPrSessionId(projectRoot, repo.url, detail.headRef);
190
+ const onSessionId = (sessionId) => {
191
+ void savePrSessionId(projectRoot, repo.url, detail.headRef, sessionId);
192
+ };
193
+
183
194
  // Re-ground the run on any work earlier runs already committed to this
184
195
  // branch. Without this, a re-invocation triggered by a PR comment loses the
185
196
  // context of what was done and re-implements or reverts prior changes.
@@ -266,6 +277,8 @@ export async function processPullRequestImplementation({
266
277
  timeoutMs: parseOptionalInt(config.CLAUDE_TIMEOUT_MS),
267
278
  issueKey,
268
279
  logger,
280
+ resumeSessionId,
281
+ onSessionId,
269
282
  onSpawn: (child) => {
270
283
  void updatePrSubtaskState("prImplementation", {
271
284
  phase: "implementation",
@@ -315,6 +328,8 @@ export async function processPullRequestImplementation({
315
328
  timeoutMs: parseOptionalInt(config.CLAUDE_TIMEOUT_MS),
316
329
  issueKey,
317
330
  logger,
331
+ resumeSessionId,
332
+ onSessionId,
318
333
  onSpawn: (child) => {
319
334
  void updatePrSubtaskState("prImplementation", {
320
335
  phase: "implementation",