claude-teammate 0.1.199 → 0.1.201

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.199",
3
+ "version": "0.1.201",
4
4
  "description": "CLI bootstrapper for Claude Teammate.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -585,11 +585,18 @@ export function buildEpicMemoryCleanupSystemPrompt() {
585
585
  "Remove obvious, redundant, contradicted, or one-off ticket details.",
586
586
  LANGUAGE_PRESERVATION_RULES,
587
587
  "Do not invent knowledge that is not grounded in the provided memory snapshot.",
588
+ "Also extract repo_branches: for each repository URL in the known repos list (or mentioned in the candidate updates), if the candidate updates specify the TARGET/BASE branch — the branch the PR or MR should merge INTO (e.g. 'tạo MR vào nhánh main', 'merge into develop', 'PR target is staging', 'nhánh chính là develop') — emit {url, branch} for it. Do NOT capture implementation/feature branch names (the short-lived branch being created for this task). Only include repos where a base/target branch is explicitly mentioned.",
589
+ "Also extract pr_repo_url: if the candidate updates indicate which specific repository should receive the pull request or merge request (e.g. 'tạo PR vào org/backend', 'create PR in org/frontend', 'MR goes to repo X'), set this to that repository's full URL from the known repos list. Set to empty string if not mentioned or ambiguous.",
588
590
  "Return only structured output matching the schema."
589
591
  ].join(" ");
590
592
  }
591
593
 
592
594
  export function buildEpicMemoryCleanupUserPrompt(input) {
595
+ const knownRepos = (input.epicMemory?.repos || []).map((r) => r.url).filter(Boolean);
596
+ const reposLine = knownRepos.length > 0
597
+ ? `\nKnown repos:\n${knownRepos.map((u) => `- ${u}`).join("\n")}`
598
+ : "";
599
+
593
600
  return `Update this epic memory facts and guardrails and clean it up.
594
601
 
595
602
  Reason:
@@ -597,7 +604,7 @@ ${input.reason || "(none)"}
597
604
 
598
605
  Current epic memory:
599
606
  ${JSON.stringify(input.epicMemory || {}, null, 2)}
600
-
607
+ ${reposLine}
601
608
  Latest candidate updates:
602
609
  ${JSON.stringify(input.candidateUpdates || {}, null, 2)}
603
610
 
@@ -121,7 +121,13 @@ export function validateEpicMemoryCleanupResult(result) {
121
121
  : [],
122
122
  guardrails: Array.isArray(result.guardrails)
123
123
  ? result.guardrails.map((item) => String(item).trim()).filter(Boolean)
124
- : []
124
+ : [],
125
+ repo_branches: Array.isArray(result.repo_branches)
126
+ ? result.repo_branches
127
+ .filter((rb) => rb && typeof rb.url === "string" && rb.url.trim())
128
+ .map((rb) => ({ url: String(rb.url).trim(), branch: String(rb.branch || "").trim() }))
129
+ : [],
130
+ pr_repo_url: typeof result.pr_repo_url === "string" ? result.pr_repo_url.trim() : ""
125
131
  };
126
132
  }
127
133
 
package/src/claude.js CHANGED
@@ -247,16 +247,25 @@ const EPIC_MEMORY_CLEANUP_SCHEMA = {
247
247
  properties: {
248
248
  facts: {
249
249
  type: "array",
250
- items: {
251
- type: "string"
252
- }
250
+ items: { type: "string" }
253
251
  },
254
252
  guardrails: {
253
+ type: "array",
254
+ items: { type: "string" }
255
+ },
256
+ repo_branches: {
255
257
  type: "array",
256
258
  items: {
257
- type: "string"
259
+ type: "object",
260
+ additionalProperties: false,
261
+ properties: {
262
+ url: { type: "string" },
263
+ branch: { type: "string" }
264
+ },
265
+ required: ["url", "branch"]
258
266
  }
259
- }
267
+ },
268
+ pr_repo_url: { type: "string" }
260
269
  },
261
270
  required: ["facts", "guardrails"]
262
271
  };
package/src/memory.js CHANGED
@@ -190,10 +190,12 @@ function normalizeEpicMemoryData(data) {
190
190
 
191
191
  normalized.repos = uniqueRepos(normalized.repos).map((repo) => ({
192
192
  url: repo.url,
193
- local_path: repo.local_path || ""
193
+ local_path: repo.local_path || "",
194
+ working_branch: repo.working_branch || ""
194
195
  }));
195
196
  normalized.facts = [...new Set(normalized.facts.map((fact) => String(fact).trim()).filter(Boolean))];
196
197
  normalized.guardrails = [...new Set(normalized.guardrails.map((guardrail) => String(guardrail).trim()).filter(Boolean))];
198
+ normalized.pr_repo_url = String(normalized.pr_repo_url || "").trim();
197
199
 
198
200
  delete normalized.repo_url;
199
201
  delete normalized.local_repo_path;
@@ -386,13 +388,15 @@ function uniqueRepos(repos) {
386
388
 
387
389
  const existing = seen.get(repo.url) ?? {
388
390
  url: repo.url,
389
- local_path: ""
391
+ local_path: "",
392
+ working_branch: ""
390
393
  };
391
394
 
392
395
  seen.set(repo.url, {
393
396
  ...existing,
394
397
  ...repo,
395
- local_path: repo.local_path || existing.local_path || ""
398
+ local_path: repo.local_path || existing.local_path || "",
399
+ working_branch: repo.working_branch || existing.working_branch || ""
396
400
  });
397
401
  }
398
402
 
@@ -121,10 +121,16 @@ export async function saveMaintainedEpicMemory({
121
121
  issueKey: issue.key,
122
122
  logger
123
123
  });
124
+ const updatedRepos = applyRepoBranchUpdates(
125
+ startingEpicMemory.repos || [],
126
+ cleaned.repo_branches || []
127
+ );
124
128
  nextEpicMemory = normalizeEpicMemoryContent({
125
129
  ...startingEpicMemory,
126
130
  facts: cleaned.facts || [],
127
- guardrails: cleaned.guardrails || []
131
+ guardrails: cleaned.guardrails || [],
132
+ repos: updatedRepos,
133
+ pr_repo_url: cleaned.pr_repo_url || startingEpicMemory.pr_repo_url || ""
128
134
  });
129
135
  if (logger) {
130
136
  await logger.info("Updated epic memory", {
@@ -230,10 +236,30 @@ function hasEpicMemoryContentChanges(current, next) {
230
236
  JSON.stringify(dedupeTextValues(current?.facts || [])) !==
231
237
  JSON.stringify(dedupeTextValues(next?.facts || [])) ||
232
238
  JSON.stringify(dedupeTextValues(current?.guardrails || [])) !==
233
- JSON.stringify(dedupeTextValues(next?.guardrails || []))
239
+ JSON.stringify(dedupeTextValues(next?.guardrails || [])) ||
240
+ (current?.pr_repo_url || "") !== (next?.pr_repo_url || "") ||
241
+ JSON.stringify(current?.repos || []) !== JSON.stringify(next?.repos || [])
234
242
  );
235
243
  }
236
244
 
245
+ function applyRepoBranchUpdates(existingRepos, repoBranches) {
246
+ if (!Array.isArray(repoBranches) || repoBranches.length === 0) {
247
+ return existingRepos;
248
+ }
249
+
250
+ let repos = [...existingRepos];
251
+ for (const rb of repoBranches) {
252
+ if (!rb.url) continue;
253
+ const idx = repos.findIndex((r) => r.url === rb.url);
254
+ if (idx === -1) {
255
+ repos.push({ url: rb.url, local_path: "", working_branch: rb.branch });
256
+ } else if (rb.branch) {
257
+ repos[idx] = { ...repos[idx], working_branch: rb.branch };
258
+ }
259
+ }
260
+ return repos;
261
+ }
262
+
237
263
  export function buildReopenContext({ reopenedFromInReview, issueMemoryPath, epicMemoryPath, workerLogPath }) {
238
264
  if (!reopenedFromInReview) {
239
265
  return null;
@@ -256,7 +256,10 @@ export async function processGitHubIssue({
256
256
  return buildGitHubIssueState(detail, githubIssueMemory, "blocked");
257
257
  }
258
258
 
259
- const defaultBranch = await github.getDefaultBranch(githubIssueMemory.repo_url);
259
+ const repoEpicEntry = (approvalEpicMemory?.repos || []).find(
260
+ (r) => r.url === githubIssueMemory.repo_url
261
+ );
262
+ const baseBranch = repoEpicEntry?.working_branch || await github.getDefaultBranch(githubIssueMemory.repo_url);
260
263
  await services.pullLatest(githubIssueMemory.local_path);
261
264
 
262
265
  // Use a short-lived worktree to create the branch so the primary clone
@@ -270,9 +273,9 @@ export async function processGitHubIssue({
270
273
  githubIssueMemory.local_path,
271
274
  nextBranchName
272
275
  );
273
- await services.ensureBranchFromDefault(branchWorktree, nextBranchName, defaultBranch);
276
+ await services.ensureBranchFromDefault(branchWorktree, nextBranchName, baseBranch);
274
277
  } else {
275
- await services.ensureBranchFromDefault(githubIssueMemory.local_path, nextBranchName, defaultBranch);
278
+ await services.ensureBranchFromDefault(githubIssueMemory.local_path, nextBranchName, baseBranch);
276
279
  }
277
280
  } finally {
278
281
  if (branchWorktree) {
@@ -283,7 +286,7 @@ export async function processGitHubIssue({
283
286
  title: detail.title,
284
287
  body: buildInitialPullRequestBody(detail.number, detail.body),
285
288
  head: nextBranchName,
286
- base: defaultBranch,
289
+ base: baseBranch,
287
290
  draft: true
288
291
  });
289
292
  githubIssueMemory.pr_url = pullRequest.url;
@@ -206,14 +206,15 @@ export function resetIssueMemoryForCommentRestart(issueMemory, commentId) {
206
206
 
207
207
  export function deriveTaskRepos(detail, epicMemory, forgeRegistry) {
208
208
  const rememberedRepos = Array.isArray(epicMemory?.repos) ? epicMemory.repos : [];
209
- const repoUrls = [
210
- ...rememberedRepos.map((repo) => repo?.url).filter(Boolean),
211
- ...extractRepoUrls(detail?.descriptionText || ""),
212
- ...findRepoUrlsInComments(Array.isArray(detail?.comments) ? detail.comments : [])
213
- ];
209
+ const knownRepoUrls = rememberedRepos.map((r) => r.url).filter(Boolean);
210
+ const descriptionPairs = extractRepoBranchPairs(detail?.descriptionText || "");
211
+ const commentPairs = findRepoBranchPairsInComments(
212
+ Array.isArray(detail?.comments) ? detail.comments : [],
213
+ knownRepoUrls
214
+ );
214
215
 
215
216
  return filterReposForActiveForge(
216
- mergeRepoUrls(rememberedRepos, repoUrls),
217
+ mergeRepoUrls(rememberedRepos, [...descriptionPairs, ...commentPairs]),
217
218
  forgeRegistry
218
219
  );
219
220
  }
@@ -233,7 +234,7 @@ export function filterReposForActiveForge(repos, forgeRegistry) {
233
234
  });
234
235
  }
235
236
 
236
- export function selectIssueCreationRepo({ detail, issueMemory, repos }) {
237
+ export function selectIssueCreationRepo({ detail, issueMemory, repos, epicMemory }) {
237
238
  if (!Array.isArray(repos) || repos.length === 0) {
238
239
  return null;
239
240
  }
@@ -243,6 +244,14 @@ export function selectIssueCreationRepo({ detail, issueMemory, repos }) {
243
244
  }
244
245
 
245
246
  const repoByUrl = new Map(repos.map((repo) => [String(repo.url || "").trim(), repo]));
247
+
248
+ if (epicMemory?.pr_repo_url) {
249
+ const prRepo = repoByUrl.get(String(epicMemory.pr_repo_url).trim());
250
+ if (prRepo) {
251
+ return prRepo;
252
+ }
253
+ }
254
+
246
255
  const latestExplicitRepo = getLatestExplicitIssueCreationRepo(detail, repoByUrl);
247
256
  if (latestExplicitRepo) {
248
257
  return latestExplicitRepo;
@@ -414,19 +423,53 @@ function ensureImplementationInstruction(body) {
414
423
  return `${normalizedBody}\n\n---\n\n${IMPLEMENTATION_INSTRUCTION}`.trim();
415
424
  }
416
425
 
417
- function findRepoUrlsInComments(comments) {
418
- const repoUrls = [];
426
+ const BRANCH_KEYWORD_RE = /\b(?:branch|nhánh)\s*[:=]\s*([a-zA-Z0-9][a-zA-Z0-9_\-./]*)/iu;
427
+
428
+ function extractRepoBranchPairs(text) {
429
+ const str = String(text || "");
430
+ const urls = extractRepoUrls(str);
431
+ if (urls.length === 0) {
432
+ return [];
433
+ }
434
+
435
+ if (urls.length === 1) {
436
+ const branchMatch = str.match(BRANCH_KEYWORD_RE);
437
+ return [{ url: urls[0], working_branch: branchMatch ? branchMatch[1] : "" }];
438
+ }
439
+
440
+ return urls.map((url) => {
441
+ const lineWithUrl = str.split("\n").find((line) => line.includes(url)) || "";
442
+ const lineBranchMatch = lineWithUrl.match(BRANCH_KEYWORD_RE);
443
+ return { url, working_branch: lineBranchMatch ? lineBranchMatch[1] : "" };
444
+ });
445
+ }
446
+
447
+ function findRepoBranchPairsInComments(comments, knownRepoUrls = []) {
448
+ const seen = new Map();
419
449
 
420
450
  for (const comment of [...comments].reverse()) {
421
- const urls = extractRepoUrls(comment.bodyText);
422
- for (const url of urls) {
423
- if (!repoUrls.includes(url)) {
424
- repoUrls.push(url);
451
+ const pairs = extractRepoBranchPairs(comment.bodyText || "");
452
+
453
+ for (const pair of pairs) {
454
+ if (!seen.has(pair.url)) {
455
+ seen.set(pair.url, pair.working_branch);
456
+ } else if (pair.working_branch && !seen.get(pair.url)) {
457
+ seen.set(pair.url, pair.working_branch);
458
+ }
459
+ }
460
+
461
+ // Branch-only comment (no URL): when there's exactly one known repo, apply the branch to it.
462
+ // Newest-first iteration ensures the latest explicit branch wins.
463
+ if (knownRepoUrls.length === 1 && pairs.length === 0) {
464
+ const url = knownRepoUrls[0];
465
+ const branchMatch = String(comment.bodyText || "").match(BRANCH_KEYWORD_RE);
466
+ if (branchMatch && (!seen.has(url) || !seen.get(url))) {
467
+ seen.set(url, branchMatch[1]);
425
468
  }
426
469
  }
427
470
  }
428
471
 
429
- return repoUrls;
472
+ return [...seen.entries()].map(([url, working_branch]) => ({ url, working_branch }));
430
473
  }
431
474
 
432
475
  function getLatestComment(comments) {
@@ -489,15 +532,17 @@ function isBlockedOrFailedIssueState(detail, issueMemory, blockedLabel) {
489
532
  );
490
533
  }
491
534
 
492
- function mergeRepoUrls(existingRepos, repoUrls) {
535
+ function mergeRepoUrls(existingRepos, repoPairs) {
493
536
  const merged = Array.isArray(existingRepos) ? [...existingRepos] : [];
494
537
 
495
- for (const url of repoUrls) {
496
- if (!merged.some((repo) => normalizeRepoIdentity(repo.url) === normalizeRepoIdentity(url))) {
497
- merged.push({
498
- url,
499
- local_path: ""
500
- });
538
+ for (const pair of repoPairs) {
539
+ const url = pair.url;
540
+ const workingBranch = pair.working_branch || "";
541
+ const existingIdx = merged.findIndex((repo) => normalizeRepoIdentity(repo.url) === normalizeRepoIdentity(url));
542
+ if (existingIdx === -1) {
543
+ merged.push({ url, local_path: "", working_branch: workingBranch });
544
+ } else if (workingBranch) {
545
+ merged[existingIdx] = { ...merged[existingIdx], working_branch: workingBranch };
501
546
  }
502
547
  }
503
548
 
@@ -159,6 +159,7 @@ export async function continueGitHubIssueCreation({
159
159
  issueMemoryRecord,
160
160
  issueMemory,
161
161
  repos,
162
+ epicMemory = null,
162
163
  logger,
163
164
  claudeResult = null,
164
165
  services
@@ -168,7 +169,7 @@ export async function continueGitHubIssueCreation({
168
169
  }
169
170
 
170
171
  const latestRepoSelectionReply = getLatestHumanRepoSelectionReply(detail, botUser, jira);
171
- let selectedRepo = selectIssueCreationRepo({ detail, issueMemory, repos });
172
+ let selectedRepo = selectIssueCreationRepo({ detail, issueMemory, repos, epicMemory });
172
173
  const latestRepoPrompt = getLatestRepoSelectionPromptComment(detail, botUser, jira);
173
174
  if (!selectedRepo && latestRepoSelectionReply) {
174
175
  const fallback = await services.runClaudeRepoSelection({
@@ -170,6 +170,10 @@ export async function processJiraIssue({
170
170
  services
171
171
  }));
172
172
 
173
+ // Re-derive after LLM may have updated epicMemory (repo branches, pr_repo_url).
174
+ // Regex derivation uses epicMemory.repos as base so LLM-set branches are preserved.
175
+ liveRepos = deriveTaskRepos(detail, epicMemory, forgeRegistry);
176
+
173
177
  let reopenedFromInReview = false;
174
178
  if (isInReviewStatus(detail.status) && hasNewHumanReplyWhileWaiting(detail, botUser, jira)) {
175
179
  const transition = await jira.transitionIssueToStatus(detail.key, "In Progress");
@@ -197,9 +201,14 @@ export async function processJiraIssue({
197
201
  await pickUpToDoJiraIssue({ detail, jira, logger, saveProgress });
198
202
  }
199
203
 
200
- const previousRepoUrls = Array.isArray(epicMemory?.repos) ? epicMemory.repos.map((repo) => repo.url) : [];
204
+ const previousRepos = Array.isArray(epicMemory?.repos) ? epicMemory.repos : [];
205
+ const previousRepoUrls = previousRepos.map((repo) => repo.url);
201
206
  const nextRepoUrls = liveRepos.map((repo) => repo.url);
202
- if (JSON.stringify(previousRepoUrls) !== JSON.stringify(nextRepoUrls)) {
207
+ const branchChanged = liveRepos.some((repo) => {
208
+ const prev = previousRepos.find((r) => r.url === repo.url);
209
+ return (repo.working_branch || "") !== (prev?.working_branch || "");
210
+ });
211
+ if (JSON.stringify(previousRepoUrls) !== JSON.stringify(nextRepoUrls) || branchChanged) {
203
212
  epicMemory.repos = liveRepos;
204
213
  epicMemory = await services.saveEpicMemory(epicMemoryRecord.filePath, detail, epicMemory);
205
214
  await logger.info("Updated epic repo knowledge", {
@@ -455,6 +464,7 @@ export async function processJiraIssue({
455
464
  issueMemoryRecord,
456
465
  issueMemory,
457
466
  repos: liveRepos,
467
+ epicMemory,
458
468
  logger,
459
469
  claudeResult,
460
470
  services