claude-teammate 0.1.280 → 0.1.282

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.280",
3
+ "version": "0.1.282",
4
4
  "description": "CLI bootstrapper for Claude Teammate.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -72,6 +72,16 @@ export function parseClaudeOutput(output) {
72
72
  return direct;
73
73
  }
74
74
 
75
+ // Skill-fix schema: { analysis, files, reason }
76
+ if ("analysis" in direct && Array.isArray(direct.files)) {
77
+ return direct;
78
+ }
79
+
80
+ // Skill-correction schema: { isCorrection, skillName, correctionSummary }
81
+ if ("isCorrection" in direct) {
82
+ return direct;
83
+ }
84
+
75
85
  if ("result" in direct && typeof direct.result === "string") {
76
86
  return JSON.parse(direct.result);
77
87
  }
package/src/claude.js CHANGED
@@ -536,8 +536,10 @@ export async function runClaudeSkillCorrectionCheck({
536
536
  return await invokeClaudeTask(
537
537
  SKILL_CORRECTION_SCHEMA,
538
538
  SKILL_CORRECTION_SYSTEM,
539
- `Previous bot response:\n<previous>\n${String(previousBotResponse || "").slice(0, 800)}\n</previous>\n\nUser message:\n<user>\n${humanMessage}\n</user>\n\nIs the user correcting specific output behavior from a skill?`,
539
+ `Previous bot response:\n<previous>\n${String(previousBotResponse || "").slice(0, 2500)}\n</previous>\n\nUser message:\n<user>\n${humanMessage}\n</user>\n\nIs the user correcting specific output behavior from a skill?`,
540
540
  {
541
+ model: "haiku",
542
+ effort: "low",
541
543
  runOpts: {
542
544
  cwd,
543
545
  timeout: timeoutMs || 30_000,
@@ -564,6 +566,7 @@ export function triggerSkillFeedbackFix({
564
566
  skillName,
565
567
  correctionSummary,
566
568
  projectRoot,
569
+ projectRoots,
567
570
  eventsRoot,
568
571
  logger,
569
572
  epicContext,
@@ -573,6 +576,7 @@ export function triggerSkillFeedbackFix({
573
576
  skillName,
574
577
  correctionSummary,
575
578
  projectRoot,
579
+ projectRoots,
576
580
  eventsRoot: eventsRoot || CLAUDE_TEAMMATE_ROOT,
577
581
  logger,
578
582
  invokeClaudeTask,
@@ -14,6 +14,14 @@ import { findSkillLocation } from "./locator.js";
14
14
  * Attribution: after a Skill is successfully loaded, it becomes the "active skill".
15
15
  * Any subsequent tool error is attributed to that skill until another Skill is loaded.
16
16
  */
17
+ // After this many attributable (Bash + mcp__) tool results without a new skill
18
+ // load, close the attribution window. Prevents errors from unrelated work done
19
+ // long after the skill completed being wrongly attributed to it.
20
+ const MAX_ATTRIBUTION_DEPTH = 20;
21
+ // Cap the trail in inferSilentRecovery to prevent unbounded memory growth
22
+ // in long tasks that call many tools under one active skill.
23
+ const MAX_TRAIL_SIZE = 100;
24
+
17
25
  export function extractSkillFailures(streamLines) {
18
26
  const failures = [];
19
27
  // Dedup by (skill, errorType): a single skill can surface multiple distinct
@@ -21,6 +29,7 @@ export function extractSkillFailures(streamLines) {
21
29
  // we want each captured. Same errorType repeating is collapsed.
22
30
  const failedKeys = new Set();
23
31
  let activeSkillName = null; // most recently loaded skill
32
+ let activeSkillCallCount = 0; // attributable tool results since skill loaded
24
33
  const pendingSkill = new Map(); // toolUseId → skillName
25
34
  const pendingToolName = new Map(); // toolUseId → toolName (all tools)
26
35
 
@@ -66,9 +75,11 @@ export function extractSkillFailures(streamLines) {
66
75
  });
67
76
  }
68
77
  activeSkillName = null;
78
+ activeSkillCallCount = 0;
69
79
  } else {
70
80
  // Skill loaded successfully — set as active so subsequent errors are attributed to it
71
81
  activeSkillName = loadedSkillName;
82
+ activeSkillCallCount = 0;
72
83
  }
73
84
  continue;
74
85
  }
@@ -78,6 +89,14 @@ export function extractSkillFailures(streamLines) {
78
89
  // Edit/Grep/Glob/Task/etc.) are general-purpose and their failures are
79
90
  // typically caused by the user prompt, not by skill instructions.
80
91
  const attributable = toolName === "Bash" || (typeof toolName === "string" && toolName.startsWith("mcp__"));
92
+ // Count every attributable tool result and close the window once MAX_ATTRIBUTION_DEPTH
93
+ // is reached. Prevents errors from unrelated work done long after the skill completed
94
+ // from being wrongly attributed to it.
95
+ if (activeSkillName && attributable) {
96
+ if (++activeSkillCallCount > MAX_ATTRIBUTION_DEPTH) {
97
+ activeSkillName = null;
98
+ }
99
+ }
81
100
  const isMcpError = attributable && toolName !== "Bash" && event.is_error === true;
82
101
  const isBashError = toolName === "Bash" && looksLikeBashError(contentText);
83
102
  if (activeSkillName && (isMcpError || isBashError)) {
@@ -171,10 +190,15 @@ export function inferSilentRecovery(streamLines) {
171
190
 
172
191
  // Only attribute errors from Bash and mcp__ tools — consistent with extractSkillFailures.
173
192
  // Read/Write/Edit/Grep failures are caused by the user prompt, not skill instructions.
193
+ // Non-attributable tools are also excluded from the trail: including them triggers
194
+ // false-positive fallback detection whenever Claude calls Read/Write/Edit after an
195
+ // mcp__ error (normal diagnostic behavior, not a skill-caused fallback).
174
196
  const isAttributable = toolName === "Bash" || (typeof toolName === "string" && toolName.startsWith("mcp__"));
175
- const isError =
176
- isAttributable && (event.is_error === true || (toolName === "Bash" && looksLikeBashError(contentText)));
197
+ if (!isAttributable) continue;
198
+ const isError = event.is_error === true || (toolName === "Bash" && looksLikeBashError(contentText));
177
199
  trail.push({ name: toolName, isError, contentText });
200
+ // Evict oldest entries once trail grows past the cap so memory stays bounded.
201
+ if (trail.length > MAX_TRAIL_SIZE) trail.splice(0, trail.length - MAX_TRAIL_SIZE);
178
202
 
179
203
  // Retry pattern: same tool seen earlier with isError=true, now called again.
180
204
  if (trail.length >= 2) {
@@ -198,10 +222,16 @@ export function inferSilentRecovery(streamLines) {
198
222
  }
199
223
  }
200
224
 
201
- // Fallback pattern: previous tool errored, this one is a different tool.
225
+ // Fallback pattern: previous tool errored and Claude switched to a different tool
226
+ // paradigm (mcp__ ↔ Bash). Limiting to paradigm shifts avoids false positives
227
+ // when a skill legitimately calls multiple distinct mcp__ tools in sequence and
228
+ // one returns a business-logic error before the next step runs.
202
229
  if (trail.length >= 2) {
203
230
  const prevEvent = trail[trail.length - 2];
204
- if (prevEvent.isError && prevEvent.name !== toolName) {
231
+ const isMcp = (n) => typeof n === "string" && n.startsWith("mcp__");
232
+ const paradigmShift =
233
+ (isMcp(prevEvent.name) && toolName === "Bash") || (prevEvent.name === "Bash" && isMcp(toolName));
234
+ if (prevEvent.isError && paradigmShift) {
205
235
  const sig = `fallback::${prevEvent.name}->${toolName}`;
206
236
  const key = `${activeSkill}::${sig}`;
207
237
  if (!emitted.has(key)) {
@@ -239,8 +269,14 @@ export function inferSilentRecovery(streamLines) {
239
269
  export async function inferBypassedSkills(streamLines, projectRoot) {
240
270
  if (!projectRoot || !streamLines?.length) return [];
241
271
 
272
+ // Per-skill-window tracking: record which tools were called while each skill
273
+ // was active so that tools called by other skills or by non-skill code do not
274
+ // pollute the mandatory-ref check for a different skill.
275
+ const pendingSkill = new Map(); // toolUseId → skillName
276
+ const pendingToolName = new Map(); // toolUseId → toolName (all tools)
277
+ let activeSkill = null;
242
278
  const loadedSkills = new Set();
243
- const calledToolNames = new Set();
279
+ const toolsCalledPerSkill = new Map(); // skillName → Set<toolName>
244
280
 
245
281
  for (const line of streamLines) {
246
282
  let event;
@@ -249,15 +285,44 @@ export async function inferBypassedSkills(streamLines, projectRoot) {
249
285
  } catch {
250
286
  continue;
251
287
  }
252
- if (event.type !== "assistant") continue;
253
- for (const block of event.message?.content || []) {
254
- if (block.type !== "tool_use" || !block.name) continue;
255
- if (block.name === "Skill") {
256
- const skillName = block.input?.skill || block.input?.name || block.input?.skillName;
257
- if (skillName) loadedSkills.add(skillName);
288
+
289
+ if (event.type === "assistant") {
290
+ for (const block of event.message?.content || []) {
291
+ if (block.type !== "tool_use" || !block.id) continue;
292
+ pendingToolName.set(block.id, block.name);
293
+ if (block.name === "Skill") {
294
+ const skillName = block.input?.skill || block.input?.name || block.input?.skillName;
295
+ if (skillName) pendingSkill.set(block.id, skillName);
296
+ }
297
+ }
298
+ continue;
299
+ }
300
+
301
+ if (event.type !== "tool_result" || !event.tool_use_id) continue;
302
+ const toolName = pendingToolName.get(event.tool_use_id);
303
+ pendingToolName.delete(event.tool_use_id);
304
+
305
+ const loadedSkillName = pendingSkill.get(event.tool_use_id);
306
+ if (loadedSkillName) {
307
+ pendingSkill.delete(event.tool_use_id);
308
+ const contentText = flattenContent(event.content);
309
+ if (event.is_error !== true && !isSkillNotFoundError(contentText)) {
310
+ activeSkill = loadedSkillName;
311
+ loadedSkills.add(loadedSkillName);
312
+ // Always reset on reload: cross-window accumulation causes false negatives
313
+ // where a mandatory tool called in window 1 masks a bypass in window 2.
314
+ toolsCalledPerSkill.set(loadedSkillName, new Set());
258
315
  } else {
259
- calledToolNames.add(block.name);
316
+ activeSkill = null;
260
317
  }
318
+ continue;
319
+ }
320
+
321
+ // Track non-Skill tool calls at result time (not call time) so attribution
322
+ // reflects the skill active when the result arrived, not when the call was issued.
323
+ if (activeSkill && toolName && toolName !== "Skill") {
324
+ const set = toolsCalledPerSkill.get(activeSkill);
325
+ if (set) set.add(toolName);
261
326
  }
262
327
  }
263
328
 
@@ -266,7 +331,7 @@ export async function inferBypassedSkills(streamLines, projectRoot) {
266
331
  const bypassed = [];
267
332
  for (const skillName of loadedSkills) {
268
333
  const location = findSkillLocation(skillName, projectRoot);
269
- if (!location) continue;
334
+ if (!location || location.type === "plugin-cache") continue;
270
335
  let skillMd;
271
336
  try {
272
337
  skillMd = await readFile(location.path, "utf8");
@@ -275,7 +340,8 @@ export async function inferBypassedSkills(streamLines, projectRoot) {
275
340
  }
276
341
  const mandatoryRefs = extractMandatoryMcpRefs(skillMd);
277
342
  if (mandatoryRefs.length === 0) continue;
278
- const missing = mandatoryRefs.filter((t) => !calledToolNames.has(t));
343
+ const calledForThisSkill = toolsCalledPerSkill.get(skillName) || new Set();
344
+ const missing = mandatoryRefs.filter((t) => !calledForThisSkill.has(t));
279
345
  if (missing.length === 0) continue;
280
346
  // Partial bypass also counts: even if some mandatory tools were called,
281
347
  // missing any single one of them means the skill's contract was not met.
@@ -288,7 +354,7 @@ export async function inferBypassedSkills(streamLines, projectRoot) {
288
354
  ", "
289
355
  )}] (per MUST/MANDATORY/REQUIRED language in SKILL.md) but Claude called none of them. The tool is likely unavailable or the directive is unclear; either rewrite the skill to use available tools or relax the mandatory language.`
290
356
  : `Skill "${skillName}" mandates tools [${mandatoryRefs.join(", ")}] but Claude only called [${mandatoryRefs
291
- .filter((t) => calledToolNames.has(t))
357
+ .filter((t) => calledForThisSkill.has(t))
292
358
  .join(", ")}] and skipped [${missing.join(
293
359
  ", "
294
360
  )}]. Either the missing tools are unavailable in this environment or the SKILL.md directive does not make their requirement clear enough — rewrite to remove the dependency or strengthen the instruction.`
@@ -302,7 +368,7 @@ function extractMandatoryMcpRefs(skillMd) {
302
368
  const mandatory = /\b(?:MUST|MANDATORY|REQUIRED|REQUIRE)\b/i;
303
369
  const mcpRef = /\bmcp__[a-zA-Z0-9_-]+__[a-zA-Z0-9_-]+/g;
304
370
  const lines = skillMd.split(/\r?\n/);
305
- // Window: line with MUST + next 4 lines. Catches "MUST call:\n\n mcp__..." patterns.
371
+ // Window: line with MUST + next 7 lines. Catches "MUST call:\n\n mcp__..." patterns.
306
372
  for (let i = 0; i < lines.length; i++) {
307
373
  if (!mandatory.test(lines[i])) continue;
308
374
  const window = lines.slice(i, Math.min(i + 8, lines.length)).join("\n");
@@ -335,21 +401,22 @@ function isSkillNotFoundError(text) {
335
401
  // each pattern below corresponds to a real error indicator, not a generic word.
336
402
  function looksLikeBashError(text) {
337
403
  return (
338
- // Python tracebacks and named exceptions
339
- /\bSyntaxError\b/.test(text) ||
340
- /\bNameError\b/.test(text) ||
341
- /\bTypeError\b/.test(text) ||
342
- /\bAttributeError\b/.test(text) ||
343
- /\bImportError\b/.test(text) ||
344
- /\bModuleNotFoundError\b/.test(text) ||
345
- /\bValueError\b/.test(text) ||
346
- /\bKeyError\b/.test(text) ||
347
- /\bIndexError\b/.test(text) ||
348
- /\bRuntimeError\b/.test(text) ||
349
- /\bFileNotFoundError\b/.test(text) ||
350
- /\bJSONDecodeError\b/.test(text) ||
351
- /\bOSError\b/.test(text) ||
352
- /\bAssertionError\b/.test(text) ||
404
+ // Python tracebacks and named exceptions — require ":" to avoid matching
405
+ // class names in test output, documentation, or log messages (e.g. "0 TypeError cases").
406
+ /\bSyntaxError:/.test(text) ||
407
+ /\bNameError:/.test(text) ||
408
+ /\bTypeError:/.test(text) ||
409
+ /\bAttributeError:/.test(text) ||
410
+ /\bImportError:/.test(text) ||
411
+ /\bModuleNotFoundError:/.test(text) ||
412
+ /\bValueError:/.test(text) ||
413
+ /\bKeyError:/.test(text) ||
414
+ /\bIndexError:/.test(text) ||
415
+ /\bRuntimeError:/.test(text) ||
416
+ /\bFileNotFoundError:/.test(text) ||
417
+ /\bJSONDecodeError:/.test(text) ||
418
+ /\bOSError:/.test(text) ||
419
+ /\bAssertionError:/.test(text) ||
353
420
  /\bTraceback \(most recent call last\)/.test(text) ||
354
421
  // Node.js / npm / build tool errors
355
422
  /\bnpm\s+ERR!/.test(text) ||
@@ -360,7 +427,7 @@ function looksLikeBashError(text) {
360
427
  /\bCannot find module\b/i.test(text) ||
361
428
  /\bis not recognized as an? (?:internal or external )?command\b/i.test(text) ||
362
429
  // POSIX shell + filesystem
363
- /\bcommand not found\b/.test(text) ||
430
+ /:\s*command not found\b/.test(text) ||
364
431
  /\bNo such file or directory\b/.test(text) ||
365
432
  /\bpermission denied\b/i.test(text) ||
366
433
  /\[Errno\s+\d+\]/.test(text) ||
@@ -1,9 +1,13 @@
1
- import { execFileSync } from "node:child_process";
2
- import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
5
  import path from "node:path";
5
6
  import process from "node:process";
6
- import { parseRepoUrl } from "../forge/repo-host.js";
7
+ import { promisify } from "node:util";
8
+ import { buildGitEnvForRepoUrl, parseRepoUrl } from "../forge/repo-host.js";
9
+
10
+ const execFileAsync = promisify(execFile);
7
11
 
8
12
  // Multi-file schema: each file in the skill directory can be fixed independently.
9
13
  // Backward-compat: single-file skills only need to return [{path:"SKILL.md", content}].
@@ -39,7 +43,7 @@ Rules:
39
43
  - Each file path must be relative to the skill directory (e.g. "SKILL.md", "scripts/run.py")
40
44
  - reason must be one sentence explaining the root cause`;
41
45
 
42
- const SKILL_FIX_TIMEOUT_MS = 60_000;
46
+ const SKILL_FIX_TIMEOUT_MS = 90_000;
43
47
 
44
48
  // Per-repo serialization: one PR creation at a time per repo. Different repos
45
49
  // run in parallel. Keyed by absolute project root path. Worktree creation under
@@ -57,10 +61,24 @@ export async function readSkillFiles(skillDir, _subdir = "") {
57
61
  for (const entry of entries) {
58
62
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
59
63
  const relPath = _subdir ? `${_subdir}/${entry.name}` : entry.name;
60
- if (entry.isFile()) {
61
- const content = await readFile(path.join(skillDir, relPath), "utf8").catch(() => null);
64
+ const absPath = path.join(skillDir, relPath);
65
+ // Follow symlinks: entry.isFile()/isDirectory() return false for symlinks;
66
+ // resolve via stat() so symlinked scripts and subdirs are included.
67
+ let isFile = entry.isFile();
68
+ let isDir = entry.isDirectory();
69
+ if (entry.isSymbolicLink()) {
70
+ try {
71
+ const s = await stat(absPath);
72
+ isFile = s.isFile();
73
+ isDir = s.isDirectory();
74
+ } catch {
75
+ continue; // dangling symlink — skip
76
+ }
77
+ }
78
+ if (isFile) {
79
+ const content = await readFile(absPath, "utf8").catch(() => null);
62
80
  if (content !== null) files.push({ path: relPath, content });
63
- } else if (entry.isDirectory() && !_subdir) {
81
+ } else if (isDir && !_subdir) {
64
82
  // one level of subdirectory traversal
65
83
  files.push(...(await readSkillFiles(skillDir, entry.name)));
66
84
  }
@@ -103,6 +121,7 @@ Produce fixed versions. Return all skill files in the files array (include unmod
103
121
 
104
122
  try {
105
123
  return await invokeClaudeTask(SKILL_FIX_SCHEMA, SKILL_FIX_SYSTEM, userPrompt, {
124
+ effort: "low",
106
125
  runOpts: {
107
126
  cwd: projectRoot,
108
127
  timeout: SKILL_FIX_TIMEOUT_MS,
@@ -132,9 +151,31 @@ Produce fixed versions. Return all skill files in the files array (include unmod
132
151
  export async function applySkillFix({ skillName, files, reason, location, projectRoot, logger }) {
133
152
  if (location.type === "global") {
134
153
  try {
135
- await writeSkillFiles(files, location.dir);
136
- logger?.info("skill-fix: patched global skill", { skill: skillName, files: files.length });
137
- return { status: "patched", path: location.dir };
154
+ // No-op check: skip write if generated content matches what's already on disk.
155
+ const resolvedDir = path.resolve(location.dir);
156
+ let anyChanged = false;
157
+ for (const { path: relPath, content } of files) {
158
+ if (path.isAbsolute(relPath) || relPath.includes("..")) continue;
159
+ const absPath = path.resolve(path.join(location.dir, relPath));
160
+ if (!absPath.startsWith(resolvedDir + path.sep) && absPath !== resolvedDir) continue;
161
+ try {
162
+ const existing = await readFile(absPath, "utf8");
163
+ if (existing !== content) {
164
+ anyChanged = true;
165
+ break;
166
+ }
167
+ } catch {
168
+ anyChanged = true; // file doesn't exist yet
169
+ break;
170
+ }
171
+ }
172
+ if (!anyChanged) {
173
+ logger?.info("skill-fix: global skill already up to date, skipping", { skill: skillName });
174
+ return { status: "no-op" };
175
+ }
176
+ const written = await writeSkillFiles(files, location.dir);
177
+ logger?.info("skill-fix: patched global skill", { skill: skillName, files: written });
178
+ return { status: "patched", path: location.dir, filesWritten: written };
138
179
  } catch (err) {
139
180
  logger?.error("skill-fix: global patch failed", { skill: skillName, error: err?.message });
140
181
  return { status: "error", error: err?.message };
@@ -165,65 +206,89 @@ export async function applySkillFix({ skillName, files, reason, location, projec
165
206
  */
166
207
  async function writeSkillFiles(files, skillDir) {
167
208
  const resolvedDir = path.resolve(skillDir);
209
+ let written = 0;
168
210
  for (const { path: relPath, content } of files) {
169
211
  if (path.isAbsolute(relPath) || relPath.includes("..")) continue;
170
212
  const absPath = path.resolve(path.join(skillDir, relPath));
171
213
  if (!absPath.startsWith(resolvedDir + path.sep) && absPath !== resolvedDir) continue;
172
214
  await mkdir(path.dirname(absPath), { recursive: true });
173
215
  await writeFile(absPath, content, "utf8");
216
+ written++;
174
217
  }
218
+ return written;
175
219
  }
176
220
 
177
221
  function sanitizeBranchSegment(name) {
178
- return name
222
+ const sanitized = name
179
223
  .replace(/[^a-z0-9-]/gi, "-")
180
224
  .replace(/-+/g, "-")
181
225
  .replace(/^-|-$/g, "")
182
226
  .toLowerCase();
227
+ // Short hash prevents collision when distinct skill names sanitize to the same string
228
+ // (e.g. "my-skill" and "my_skill" both become "my-skill" without the hash).
229
+ const hash = createHash("sha1").update(name).digest("hex").slice(0, 6);
230
+ return `${sanitized}-${hash}`;
183
231
  }
184
232
 
185
- function detectProvider(projectRoot) {
233
+ async function detectProvider(projectRoot) {
186
234
  try {
187
- const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
235
+ const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], {
188
236
  cwd: projectRoot,
189
237
  encoding: "utf8",
190
238
  timeout: 5000
191
- }).trim();
239
+ });
240
+ const remoteUrl = stdout.trim();
192
241
  const repo = parseRepoUrl(remoteUrl);
193
- return { provider: repo.provider, repo };
242
+ return { provider: repo.provider, repo, remoteUrl };
194
243
  } catch {
195
- return { provider: "github", repo: null };
244
+ return { provider: "github", repo: null, remoteUrl: null };
196
245
  }
197
246
  }
198
247
 
199
- function getDefaultBranch(projectRoot) {
248
+ async function getDefaultBranch(projectRoot) {
200
249
  // First try: symbolic-ref (instant, works when origin/HEAD is configured).
201
250
  try {
202
- return execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], {
251
+ const { stdout } = await execFileAsync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], {
203
252
  cwd: projectRoot,
204
253
  encoding: "utf8",
205
254
  timeout: 5000
206
- })
207
- .trim()
208
- .replace(/^origin\//, "");
255
+ });
256
+ return stdout.trim().replace(/^origin\//, "");
209
257
  } catch {}
210
258
  // Second try: `git remote show origin` — requires network but always reports HEAD.
211
259
  // Used when the repo was cloned with --single-branch and origin/HEAD is unset.
212
260
  try {
213
- const out = execFileSync("git", ["remote", "show", "origin"], {
261
+ const { stdout } = await execFileAsync("git", ["remote", "show", "origin"], {
214
262
  cwd: projectRoot,
215
263
  encoding: "utf8",
216
264
  timeout: 15000
217
265
  });
218
- const match = out.match(/HEAD branch:\s*(\S+)/);
266
+ const match = stdout.match(/HEAD branch:\s*(\S+)/);
219
267
  if (match?.[1] && match[1] !== "(unknown)") return match[1];
220
268
  } catch {}
269
+ // Third try: scan existing remote-tracking refs for known default names.
270
+ // Local/no-network. Handles repos cloned with --single-branch where origin/HEAD
271
+ // is unset and git remote show fails.
272
+ try {
273
+ const { stdout } = await execFileAsync("git", ["branch", "-r", "--format=%(refname:short)"], {
274
+ cwd: projectRoot,
275
+ encoding: "utf8",
276
+ timeout: 5000
277
+ });
278
+ const refs = stdout
279
+ .trim()
280
+ .split(/\r?\n/)
281
+ .map((l) => l.trim());
282
+ for (const name of ["main", "master", "develop", "trunk"]) {
283
+ if (refs.includes(`origin/${name}`)) return name;
284
+ }
285
+ } catch {}
221
286
  return "main";
222
287
  }
223
288
 
224
289
  async function createSkillFixPR({ skillName, files, reason, location, projectRoot, logger }) {
225
290
  const branch = `fix/skill-${sanitizeBranchSegment(skillName)}`;
226
- const { provider, repo } = detectProvider(projectRoot);
291
+ const { provider, repo, remoteUrl } = await detectProvider(projectRoot);
227
292
 
228
293
  // Fail fast for GitLab if token missing — without an MR API call we cannot
229
294
  // ship the fix upstream, and writing into the active working tree would
@@ -234,14 +299,35 @@ async function createSkillFixPR({ skillName, files, reason, location, projectRoo
234
299
  return { status: "error", error: msg };
235
300
  }
236
301
 
237
- // Idempotency: skip if PR/MR already open
302
+ // Warn for GitHub if PAT is missing — git push will fall back to system
303
+ // credential helpers which may not be configured in containers / CI.
304
+ if (provider === "github" && !process.env.GITHUB_PAT) {
305
+ logger?.warn?.(
306
+ "skill-fix: GITHUB_PAT not set — git push will rely on system credential helper (may fail in CI/container)",
307
+ { skill: skillName }
308
+ );
309
+ }
310
+
311
+ // Build auth env so git fetch + push work in environments without a system
312
+ // credential helper (containers, CI). Mirrors how repo.js authenticates.
313
+ const gitAuthEnv = remoteUrl ? buildGitEnvForRepoUrl(remoteUrl) : undefined;
314
+
315
+ // Idempotency: skip if PR/MR already open.
316
+ // null = API unreachable; log and proceed rather than silently skipping (risk of
317
+ // duplicate is low; failing to fix would be worse than a duplicate PR).
238
318
  const existing = await findExistingPR({ branch, provider, repo, projectRoot });
239
- if (existing) {
319
+ if (existing === true) {
240
320
  logger?.info?.("skill-fix: PR/MR already open, skipping", { skill: skillName, branch });
241
321
  return { status: "pr-exists" };
242
322
  }
323
+ if (existing === null) {
324
+ logger?.warn?.("skill-fix: could not check for existing PR/MR (API error), proceeding", {
325
+ skill: skillName,
326
+ branch
327
+ });
328
+ }
243
329
 
244
- const defaultBranch = getDefaultBranch(projectRoot);
330
+ const defaultBranch = await getDefaultBranch(projectRoot);
245
331
  const safeReason = reason.slice(0, 250);
246
332
  const commitMsg = `fix(skill): auto-patch ${skillName}\n\n${safeReason}`;
247
333
  const prTitle = `fix(skill): auto-patch ${skillName}`;
@@ -255,18 +341,27 @@ async function createSkillFixPR({ skillName, files, reason, location, projectRoo
255
341
  try {
256
342
  // Pull latest default branch so the fix is based on current HEAD, not stale local state.
257
343
  try {
258
- execFileSync("git", ["fetch", "origin", defaultBranch], { cwd: projectRoot, timeout: 60000 });
259
- } catch {}
344
+ await execFileAsync("git", ["fetch", "origin", defaultBranch], {
345
+ cwd: projectRoot,
346
+ timeout: 60000,
347
+ ...(gitAuthEnv && { env: gitAuthEnv })
348
+ });
349
+ } catch (fetchErr) {
350
+ logger?.warn?.("skill-fix: fetch failed, proceeding with stale base — fix PR may conflict on merge", {
351
+ skill: skillName,
352
+ error: fetchErr?.message
353
+ });
354
+ }
260
355
 
261
356
  // Prune stale worktree registrations left by prior SIGKILL — otherwise
262
357
  // `git worktree add -B branch` fails if the branch is still "checked out"
263
358
  // at a now-deleted tmp path.
264
359
  try {
265
- execFileSync("git", ["worktree", "prune"], { cwd: projectRoot, timeout: 10000 });
360
+ await execFileAsync("git", ["worktree", "prune"], { cwd: projectRoot, timeout: 10000 });
266
361
  } catch {}
267
362
 
268
363
  // -B: create or reset branch to point at origin/<defaultBranch>
269
- execFileSync("git", ["worktree", "add", "-B", branch, worktreePath, `origin/${defaultBranch}`], {
364
+ await execFileAsync("git", ["worktree", "add", "-B", branch, worktreePath, `origin/${defaultBranch}`], {
270
365
  cwd: projectRoot,
271
366
  timeout: 15000
272
367
  });
@@ -281,34 +376,55 @@ async function createSkillFixPR({ skillName, files, reason, location, projectRoo
281
376
  await mkdir(worktreeSkillDir, { recursive: true });
282
377
  await writeSkillFiles(files, worktreeSkillDir);
283
378
 
284
- execFileSync("git", ["add", skillRelDir], { cwd: worktreePath, timeout: 10000 });
379
+ await execFileAsync("git", ["add", skillRelDir], { cwd: worktreePath, timeout: 10000 });
285
380
 
286
381
  // No-op fix detection: nothing actually changed → skip empty commit/PR.
287
- const diffStatus = execFileSync("git", ["diff", "--cached", "--name-only"], {
382
+ const { stdout: diffStatus } = await execFileAsync("git", ["diff", "--cached", "--name-only"], {
288
383
  cwd: worktreePath,
289
384
  encoding: "utf8",
290
385
  timeout: 10000
291
- }).trim();
292
- if (!diffStatus) {
386
+ });
387
+ if (!diffStatus.trim()) {
293
388
  logger?.info?.("skill-fix: no file changes after generation, skipping PR", { skill: skillName });
294
389
  return { status: "no-op" };
295
390
  }
296
391
 
297
- execFileSync("git", ["commit", "-m", commitMsg], { cwd: worktreePath, timeout: 10000 });
392
+ const changedFileCount = diffStatus.trim().split("\n").filter(Boolean).length;
393
+ // Inject git identity so commit succeeds in containers where user.name/email are unset.
394
+ await execFileAsync(
395
+ "git",
396
+ [
397
+ "-c",
398
+ "user.email=skill-fixer@claude-teammate.local",
399
+ "-c",
400
+ "user.name=Claude Teammate",
401
+ "commit",
402
+ "-m",
403
+ commitMsg
404
+ ],
405
+ { cwd: worktreePath, timeout: 10000 }
406
+ );
298
407
  // --force-with-lease: safe retry after partial failure — only overwrites if remote matches expected
299
- execFileSync("git", ["push", "--force-with-lease", "-u", "origin", branch], { cwd: worktreePath, timeout: 60000 });
408
+ await execFileAsync("git", ["push", "--force-with-lease", "-u", "origin", branch], {
409
+ cwd: worktreePath,
410
+ timeout: 60000,
411
+ ...(gitAuthEnv && { env: gitAuthEnv })
412
+ });
300
413
 
301
414
  const prUrl = await openPR({ branch, prTitle, safeReason, defaultBranch, provider, repo, projectRoot });
302
415
 
303
- logger?.info?.("skill-fix: PR/MR created", { skill: skillName, pr: prUrl });
304
- return { status: "pr-created", prUrl };
416
+ logger?.info?.("skill-fix: PR/MR created", { skill: skillName, pr: prUrl, filesWritten: changedFileCount });
417
+ return { status: "pr-created", prUrl, filesWritten: changedFileCount };
305
418
  } catch (err) {
306
419
  logger?.error?.("skill-fix: PR/MR creation failed", { skill: skillName, error: err?.message });
307
420
  return { status: "error", error: err?.message };
308
421
  } finally {
309
422
  if (worktreeAttached) {
310
423
  try {
311
- execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: projectRoot, timeout: 10000 });
424
+ await execFileAsync("git", ["worktree", "remove", "--force", worktreePath], {
425
+ cwd: projectRoot,
426
+ timeout: 10000
427
+ });
312
428
  } catch {}
313
429
  }
314
430
  try {
@@ -317,6 +433,8 @@ async function createSkillFixPR({ skillName, files, reason, location, projectRoo
317
433
  }
318
434
  }
319
435
 
436
+ // Returns true (PR exists), false (no PR), or null (API call failed — caller should proceed
437
+ // with caution and log a warning rather than treating as "no PR").
320
438
  async function findExistingPR({ branch, provider, repo, projectRoot }) {
321
439
  try {
322
440
  if (provider === "gitlab" && repo) {
@@ -324,23 +442,27 @@ async function findExistingPR({ branch, provider, repo, projectRoot }) {
324
442
  if (!token) return false;
325
443
  const projectId = encodeURIComponent(repo.path);
326
444
  const url = `${repo.origin}/api/v4/projects/${projectId}/merge_requests?state=opened&source_branch=${encodeURIComponent(branch)}&per_page=1`;
327
- const out = execFileSync("curl", ["-sf", "--max-time", "30", "-H", `PRIVATE-TOKEN: ${token}`, url], {
328
- encoding: "utf8",
329
- timeout: 35000
330
- });
331
- const parsed = JSON.parse(out);
445
+ const { stdout } = await execFileAsync(
446
+ "curl",
447
+ ["-sf", "--max-time", "30", "-H", `PRIVATE-TOKEN: ${token}`, url],
448
+ { encoding: "utf8", timeout: 35000 }
449
+ );
450
+ const parsed = JSON.parse(stdout);
332
451
  if (parsed?.message) throw new Error(`GitLab API error: ${parsed.message}`);
333
452
  return Array.isArray(parsed) && parsed.length > 0;
334
453
  }
335
454
  // GitHub
336
- const out = execFileSync(
455
+ const { stdout } = await execFileAsync(
337
456
  "gh",
338
457
  ["pr", "list", "--head", branch, "--state", "open", "--json", "number", "--jq", "length"],
339
458
  { cwd: projectRoot, encoding: "utf8", timeout: 30000 }
340
- ).trim();
459
+ );
460
+ const out = stdout.trim();
341
461
  return out !== "0" && out !== "";
342
462
  } catch {
343
- return false;
463
+ // Return null so the caller can distinguish "no PR found" from "API unavailable".
464
+ // Callers must not silently treat this as "no PR" to avoid creating duplicates.
465
+ return null;
344
466
  }
345
467
  }
346
468
 
@@ -357,7 +479,7 @@ async function openPR({ branch, prTitle, safeReason, defaultBranch, provider, re
357
479
  description: safeReason,
358
480
  remove_source_branch: true
359
481
  });
360
- const out = execFileSync(
482
+ const { stdout } = await execFileAsync(
361
483
  "curl",
362
484
  [
363
485
  "-sf",
@@ -375,13 +497,13 @@ async function openPR({ branch, prTitle, safeReason, defaultBranch, provider, re
375
497
  ],
376
498
  { encoding: "utf8", timeout: 35000 }
377
499
  );
378
- const parsed = JSON.parse(out);
500
+ const parsed = JSON.parse(stdout);
379
501
  if (parsed?.message) throw new Error(`GitLab API error: ${parsed.message}`);
380
502
  return parsed.web_url || url;
381
503
  }
382
504
 
383
505
  // GitHub — `gh pr create` may print warnings before the URL; take the last non-empty line.
384
- const ghOut = execFileSync(
506
+ const { stdout: ghOut } = await execFileAsync(
385
507
  "gh",
386
508
  ["pr", "create", "--title", prTitle, "--body", safeReason, "--base", defaultBranch],
387
509
  { cwd: projectRoot, encoding: "utf8", timeout: 30000 }
@@ -1,10 +1,11 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import process from "node:process";
3
4
  import { extractSkillFailures, inferBypassedSkills, inferSilentRecovery } from "./detector.js";
4
5
  import { applySkillFix, generateSkillFix, readSkillFiles } from "./fixer.js";
5
- import { findSkillLocation } from "./locator.js";
6
+ import { findSkillLocationInRoots } from "./locator.js";
6
7
 
7
- const SKILL_FIX_EVENTS_MAX = 50;
8
+ const SKILL_FIX_EVENTS_MAX = parseInt(process.env.SKILL_FIX_EVENTS_MAX || "200", 10);
8
9
 
9
10
  // Module-level lock: prevents concurrent fix attempts for the same skill
10
11
  // (multiple tasks can detect the same failing skill simultaneously)
@@ -77,6 +78,13 @@ async function _detectAndFix({
77
78
  ];
78
79
  if (failures.length === 0) return;
79
80
 
81
+ if (!eventsRoot) {
82
+ logger?.warn?.(
83
+ "skill-fix: eventsRoot not provided — events will be written to projectRoot; pass eventsRoot to avoid writing skill-fixes.json into the cloned repo",
84
+ { projectRoot }
85
+ );
86
+ }
87
+
80
88
  await fixSkillsAsync(
81
89
  failures,
82
90
  projectRoot,
@@ -95,12 +103,19 @@ async function fixSkillsAsync(failures, projectRoot, eventsRoot, logger, invokeC
95
103
  // single fix run so the model sees the full failure picture.
96
104
  const grouped = new Map();
97
105
  for (const f of failures) {
98
- if (!f?.skillName || f.skillName === "unknown") continue;
106
+ if (!f?.skillName || f.skillName === "unknown") {
107
+ logger?.warn?.(
108
+ "skill-fix: skill name resolved to 'unknown' — Skill tool input format may have changed; check block.input key names",
109
+ { failure: f }
110
+ );
111
+ continue;
112
+ }
99
113
  const existing = grouped.get(f.skillName);
100
114
  if (!existing) {
101
115
  grouped.set(f.skillName, { ...f });
102
116
  } else {
103
- existing.errorContent = `${existing.errorContent}\n---\n[${f.errorType}]\n${f.errorContent || ""}`.slice(0, 1800);
117
+ const combined = `${existing.errorContent}\n---\n[${f.errorType}]\n${f.errorContent || ""}`;
118
+ existing.errorContent = combined.length > 1800 ? `${combined.slice(0, 1797)}…` : combined;
104
119
  // Keep the first errorType as the primary classification for telemetry,
105
120
  // but tag composite when more than one mode contributed.
106
121
  if (existing.errorType !== f.errorType && !existing.errorType.endsWith("+")) {
@@ -143,13 +158,17 @@ async function fixSkillsAsync(failures, projectRoot, eventsRoot, logger, invokeC
143
158
  * Haiku already classified it — this applies the fix using user correction as context.
144
159
  * Fire-and-forget safe: never throws.
145
160
  *
146
- * projectRoot: cloned repo path (skill lookup + git PR).
161
+ * projectRoot: primary cloned repo path (skill lookup + git PR).
162
+ * projectRoots: optional array of all cloned repo paths to search for the skill.
163
+ * Handles multi-repo projects where a skill may live in any repo.
164
+ * Falls back to [projectRoot] when omitted.
147
165
  * eventsRoot: claude-teammate root (event log destination).
148
166
  */
149
167
  export function scheduleSkillFixWithFeedback({
150
168
  skillName,
151
169
  correctionSummary,
152
170
  projectRoot,
171
+ projectRoots,
153
172
  eventsRoot,
154
173
  logger,
155
174
  invokeClaudeTask,
@@ -175,6 +194,7 @@ export function scheduleSkillFixWithFeedback({
175
194
  errorContent: `User correction: ${correctionSummary}`,
176
195
  errorType: "user-feedback",
177
196
  projectRoot,
197
+ projectRoots,
178
198
  eventsRoot: eventsRoot || projectRoot,
179
199
  logger,
180
200
  invokeClaudeTask,
@@ -190,18 +210,32 @@ async function fixSingleSkill({
190
210
  errorContent,
191
211
  errorType,
192
212
  projectRoot,
213
+ projectRoots,
193
214
  eventsRoot,
194
215
  logger,
195
216
  invokeClaudeTask,
196
217
  epicContext,
197
218
  issueKey
198
219
  }) {
199
- const location = findSkillLocation(skillName, projectRoot);
220
+ // Search all provided repo roots so multi-repo projects can find skills in any repo.
221
+ const roots = Array.isArray(projectRoots) && projectRoots.length > 0 ? projectRoots : [projectRoot];
222
+ const location = findSkillLocationInRoots(skillName, roots);
200
223
  if (!location) {
201
- logger?.info("skill-fix: skill file not found, skipping", { skill: skillName, projectRoot });
224
+ logger?.info("skill-fix: skill file not found in any project root, skipping", {
225
+ skill: skillName,
226
+ roots
227
+ });
202
228
  await appendSkillFixEvent(eventsRoot, { skill: skillName, errorType, status: "not-found" });
203
229
  return;
204
230
  }
231
+ if (location.type === "plugin-cache") {
232
+ logger?.info(
233
+ "skill-fix: plugin-cache skill is out of scope for auto-fix (owned by plugin author; update the plugin to fix)",
234
+ { skill: skillName }
235
+ );
236
+ await appendSkillFixEvent(eventsRoot, { skill: skillName, errorType, status: "scope-excluded" });
237
+ return;
238
+ }
205
239
 
206
240
  const skillFiles = await readSkillFiles(location.dir);
207
241
  if (skillFiles.length === 0) {
@@ -251,6 +285,15 @@ async function fixSingleSkill({
251
285
  return;
252
286
  }
253
287
 
288
+ // Ensure the model returned ALL files: if any input file is absent from the fix
289
+ // output, preserve its original content so we never silently drop skill files.
290
+ const returnedPaths = new Set(fix.files.map((f) => f.path));
291
+ for (const original of skillFiles) {
292
+ if (!returnedPaths.has(original.path)) {
293
+ fix.files.push({ path: original.path, content: original.content });
294
+ }
295
+ }
296
+
254
297
  const result = await applySkillFix({
255
298
  skillName,
256
299
  files: fix.files,
@@ -268,16 +311,16 @@ async function fixSingleSkill({
268
311
  status: result.status,
269
312
  prUrl: result.prUrl,
270
313
  error: result.error,
271
- files: fix.files.length
314
+ files: result.filesWritten ?? fix.files.length
272
315
  });
273
316
  }
274
317
 
275
- // In-process mutex for skill-fixes.json. The file is read-modify-written, and
276
- // concurrent fixes (different skills, different repos) can interleave reads
277
- // and lose events. Serializing all writes through a single chained promise
278
- // inside this process eliminates the race for our single-worker deployment.
318
+ // In-process mutex for skill-fixes.json, keyed per eventsRoot so different roots
319
+ // don't serialize against each other (relevant in tests and future multi-tenant use).
320
+ // The file is read-modify-written; chaining all writes through one promise per path
321
+ // eliminates interleaved-read races within this process.
279
322
  // Multi-process deployments would need a file lock (flock); not applicable here.
280
- let _eventsWriteChain = Promise.resolve();
323
+ const _eventsWriteChains = new Map();
281
324
 
282
325
  async function appendSkillFixEvent(eventsRoot, fields) {
283
326
  if (!eventsRoot) return;
@@ -297,9 +340,14 @@ async function appendSkillFixEvent(eventsRoot, fields) {
297
340
  // never throw — event logging is best-effort
298
341
  }
299
342
  };
300
- // Chain after previous job; .catch keeps chain alive on rejection.
301
- const next = _eventsWriteChain.then(job, job);
302
- _eventsWriteChain = next.catch(() => {});
343
+ const prev = _eventsWriteChains.get(eventsRoot) || Promise.resolve();
344
+ const next = prev.then(job, job);
345
+ const queued = next.catch(() => {});
346
+ _eventsWriteChains.set(eventsRoot, queued);
347
+ // Remove key once settled so the Map doesn't grow unboundedly.
348
+ queued.finally(() => {
349
+ if (_eventsWriteChains.get(eventsRoot) === queued) _eventsWriteChains.delete(eventsRoot);
350
+ });
303
351
  return next;
304
352
  }
305
353
 
@@ -3,28 +3,33 @@ import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
 
5
5
  /**
6
- * Find where a skill's SKILL.md lives.
7
- * Returns { path, type: 'repo' | 'global' } or null.
6
+ * Find where a skill's SKILL.md lives, searching multiple project roots.
7
+ * Returns { path, type: 'repo' | 'global', dir } or null.
8
8
  *
9
9
  * Skill structure on disk: {root}/skills/{skillName}/SKILL.md
10
10
  *
11
11
  * Repo skills → fix via PR.
12
12
  * Global skills → fix in-place, notify user.
13
+ * Plugin-cache → out of scope for auto-fix (owned by plugin authors).
14
+ *
15
+ * Checks all repo-level skill dirs first (in order), then global.
16
+ * This handles multi-repo projects where a skill may live in any cloned repo.
13
17
  */
14
- export function findSkillLocation(skillName, projectRoot) {
15
- // Normalize: strip leading slash, plugin prefix ("plugin:skill" → "skill")
18
+ export function findSkillLocationInRoots(skillName, projectRoots) {
16
19
  const raw = String(skillName || "")
17
20
  .replace(/^\//, "")
18
21
  .trim();
19
- const base = raw.includes(":") ? raw.split(":").pop() : raw;
20
- // Reject empty, dotfiles, or path traversal attempts
22
+ const isPluginPrefixed = raw.includes(":");
23
+ const base = isPluginPrefixed ? raw.split(":").pop() : raw;
21
24
  if (!base || base.startsWith(".") || base.includes("/") || base.includes("\\")) return null;
22
25
 
23
26
  const home = homedir();
27
+ const roots = Array.isArray(projectRoots) ? projectRoots.filter(Boolean) : [];
28
+
24
29
  // Only repo-level and user-level skill dirs are managed; plugin cache skills
25
30
  // are owned by their plugin authors and intentionally out of scope.
26
31
  const candidates = [
27
- { root: path.join(projectRoot, ".claude", "skills"), type: "repo" },
32
+ ...roots.map((root) => ({ root: path.join(root, ".claude", "skills"), type: "repo" })),
28
33
  { root: path.join(home, ".claude", "skills"), type: "global" }
29
34
  ].map((c) => ({ ...c, path: path.join(c.root, base, "SKILL.md") }));
30
35
 
@@ -32,7 +37,17 @@ export function findSkillLocation(skillName, projectRoot) {
32
37
  const resolved = path.resolve(c.path);
33
38
  return resolved.startsWith(path.resolve(c.root)) && existsSync(c.path);
34
39
  });
35
- if (!found) return null;
36
- // dir: skill directory containing SKILL.md and any companion scripts
40
+ if (!found) {
41
+ // Plugin-namespaced skills (e.g. "caveman:caveman", "plugin:figma:figma-use") that are
42
+ // not overridden by a repo or global skill are owned by their plugin author and
43
+ // intentionally out of scope for auto-fix. Return a distinct marker so callers can
44
+ // log "scope-excluded" instead of the misleading "not-found".
45
+ return isPluginPrefixed ? { type: "plugin-cache", path: null, dir: null } : null;
46
+ }
37
47
  return { ...found, dir: path.dirname(found.path) };
38
48
  }
49
+
50
+ /** Single-root convenience wrapper — backward-compatible. */
51
+ export function findSkillLocation(skillName, projectRoot) {
52
+ return findSkillLocationInRoots(skillName, projectRoot ? [projectRoot] : []);
53
+ }
@@ -255,7 +255,9 @@ export async function processJiraIssue({
255
255
  botUser,
256
256
  jira,
257
257
  projectRoot,
258
- eventsRoot: projectRoot,
258
+ // eventsRoot intentionally omitted — triggerSkillFeedbackFix defaults to
259
+ // CLAUDE_TEAMMATE_ROOT (the stable teammate process root), which is correct.
260
+ // Passing projectRoot here would write events into the cloned repo dir instead.
259
261
  liveRepos,
260
262
  issueMemory,
261
263
  issueMemoryRecord,
@@ -769,7 +771,9 @@ export async function maybeCheckSkillCorrection({
769
771
  .sort(compareCommentsNewestFirst)
770
772
  .find((c) => jira.isBotAuthor(c.author, botUser) && getCommentTimestamp(c) < humanTs);
771
773
 
772
- const repoCwd = liveRepos?.find((r) => r?.local_path)?.local_path || projectRoot;
774
+ // Collect all repo local paths so the skill fixer can search every cloned repo.
775
+ const allRepoPaths = (liveRepos || []).map((r) => r?.local_path).filter(Boolean);
776
+ const repoCwd = allRepoPaths[0] || projectRoot;
773
777
 
774
778
  // Fire-and-forget by design: run-1 already produced the broken output, run-2
775
779
  // (next poll cycle) will pick up the fixed skill. We do not block the main
@@ -791,6 +795,7 @@ export async function maybeCheckSkillCorrection({
791
795
  skillName: result.skillName,
792
796
  correctionSummary: result.correctionSummary,
793
797
  projectRoot: repoCwd,
798
+ projectRoots: allRepoPaths.length > 0 ? allRepoPaths : undefined,
794
799
  eventsRoot,
795
800
  logger,
796
801
  epicContext,