claude-teammate 0.1.280 → 0.1.281

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.281",
4
4
  "description": "CLI bootstrapper for Claude Teammate.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/claude.js CHANGED
@@ -536,7 +536,7 @@ 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
541
  runOpts: {
542
542
  cwd,
@@ -14,6 +14,11 @@ 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
+
17
22
  export function extractSkillFailures(streamLines) {
18
23
  const failures = [];
19
24
  // Dedup by (skill, errorType): a single skill can surface multiple distinct
@@ -21,6 +26,7 @@ export function extractSkillFailures(streamLines) {
21
26
  // we want each captured. Same errorType repeating is collapsed.
22
27
  const failedKeys = new Set();
23
28
  let activeSkillName = null; // most recently loaded skill
29
+ let activeSkillCallCount = 0; // attributable tool results since skill loaded
24
30
  const pendingSkill = new Map(); // toolUseId → skillName
25
31
  const pendingToolName = new Map(); // toolUseId → toolName (all tools)
26
32
 
@@ -66,9 +72,11 @@ export function extractSkillFailures(streamLines) {
66
72
  });
67
73
  }
68
74
  activeSkillName = null;
75
+ activeSkillCallCount = 0;
69
76
  } else {
70
77
  // Skill loaded successfully — set as active so subsequent errors are attributed to it
71
78
  activeSkillName = loadedSkillName;
79
+ activeSkillCallCount = 0;
72
80
  }
73
81
  continue;
74
82
  }
@@ -78,6 +86,14 @@ export function extractSkillFailures(streamLines) {
78
86
  // Edit/Grep/Glob/Task/etc.) are general-purpose and their failures are
79
87
  // typically caused by the user prompt, not by skill instructions.
80
88
  const attributable = toolName === "Bash" || (typeof toolName === "string" && toolName.startsWith("mcp__"));
89
+ // Count every attributable tool result and close the window once MAX_ATTRIBUTION_DEPTH
90
+ // is reached. Prevents errors from unrelated work done long after the skill completed
91
+ // from being wrongly attributed to it.
92
+ if (activeSkillName && attributable) {
93
+ if (++activeSkillCallCount > MAX_ATTRIBUTION_DEPTH) {
94
+ activeSkillName = null;
95
+ }
96
+ }
81
97
  const isMcpError = attributable && toolName !== "Bash" && event.is_error === true;
82
98
  const isBashError = toolName === "Bash" && looksLikeBashError(contentText);
83
99
  if (activeSkillName && (isMcpError || isBashError)) {
@@ -171,9 +187,12 @@ export function inferSilentRecovery(streamLines) {
171
187
 
172
188
  // Only attribute errors from Bash and mcp__ tools — consistent with extractSkillFailures.
173
189
  // Read/Write/Edit/Grep failures are caused by the user prompt, not skill instructions.
190
+ // Non-attributable tools are also excluded from the trail: including them triggers
191
+ // false-positive fallback detection whenever Claude calls Read/Write/Edit after an
192
+ // mcp__ error (normal diagnostic behavior, not a skill-caused fallback).
174
193
  const isAttributable = toolName === "Bash" || (typeof toolName === "string" && toolName.startsWith("mcp__"));
175
- const isError =
176
- isAttributable && (event.is_error === true || (toolName === "Bash" && looksLikeBashError(contentText)));
194
+ if (!isAttributable) continue;
195
+ const isError = event.is_error === true || (toolName === "Bash" && looksLikeBashError(contentText));
177
196
  trail.push({ name: toolName, isError, contentText });
178
197
 
179
198
  // Retry pattern: same tool seen earlier with isError=true, now called again.
@@ -198,10 +217,16 @@ export function inferSilentRecovery(streamLines) {
198
217
  }
199
218
  }
200
219
 
201
- // Fallback pattern: previous tool errored, this one is a different tool.
220
+ // Fallback pattern: previous tool errored and Claude switched to a different tool
221
+ // paradigm (mcp__ ↔ Bash). Limiting to paradigm shifts avoids false positives
222
+ // when a skill legitimately calls multiple distinct mcp__ tools in sequence and
223
+ // one returns a business-logic error before the next step runs.
202
224
  if (trail.length >= 2) {
203
225
  const prevEvent = trail[trail.length - 2];
204
- if (prevEvent.isError && prevEvent.name !== toolName) {
226
+ const isMcp = (n) => typeof n === "string" && n.startsWith("mcp__");
227
+ const paradigmShift =
228
+ (isMcp(prevEvent.name) && toolName === "Bash") || (prevEvent.name === "Bash" && isMcp(toolName));
229
+ if (prevEvent.isError && paradigmShift) {
205
230
  const sig = `fallback::${prevEvent.name}->${toolName}`;
206
231
  const key = `${activeSkill}::${sig}`;
207
232
  if (!emitted.has(key)) {
@@ -239,8 +264,14 @@ export function inferSilentRecovery(streamLines) {
239
264
  export async function inferBypassedSkills(streamLines, projectRoot) {
240
265
  if (!projectRoot || !streamLines?.length) return [];
241
266
 
267
+ // Per-skill-window tracking: record which tools were called while each skill
268
+ // was active so that tools called by other skills or by non-skill code do not
269
+ // pollute the mandatory-ref check for a different skill.
270
+ const pendingSkill = new Map(); // toolUseId → skillName
271
+ const pendingToolName = new Map(); // toolUseId → toolName (all tools)
272
+ let activeSkill = null;
242
273
  const loadedSkills = new Set();
243
- const calledToolNames = new Set();
274
+ const toolsCalledPerSkill = new Map(); // skillName → Set<toolName>
244
275
 
245
276
  for (const line of streamLines) {
246
277
  let event;
@@ -249,15 +280,44 @@ export async function inferBypassedSkills(streamLines, projectRoot) {
249
280
  } catch {
250
281
  continue;
251
282
  }
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);
283
+
284
+ if (event.type === "assistant") {
285
+ for (const block of event.message?.content || []) {
286
+ if (block.type !== "tool_use" || !block.id) continue;
287
+ pendingToolName.set(block.id, block.name);
288
+ if (block.name === "Skill") {
289
+ const skillName = block.input?.skill || block.input?.name || block.input?.skillName;
290
+ if (skillName) pendingSkill.set(block.id, skillName);
291
+ }
292
+ }
293
+ continue;
294
+ }
295
+
296
+ if (event.type !== "tool_result" || !event.tool_use_id) continue;
297
+ const toolName = pendingToolName.get(event.tool_use_id);
298
+ pendingToolName.delete(event.tool_use_id);
299
+
300
+ const loadedSkillName = pendingSkill.get(event.tool_use_id);
301
+ if (loadedSkillName) {
302
+ pendingSkill.delete(event.tool_use_id);
303
+ const contentText = flattenContent(event.content);
304
+ if (event.is_error !== true && !isSkillNotFoundError(contentText)) {
305
+ activeSkill = loadedSkillName;
306
+ loadedSkills.add(loadedSkillName);
307
+ // Always reset on reload: cross-window accumulation causes false negatives
308
+ // where a mandatory tool called in window 1 masks a bypass in window 2.
309
+ toolsCalledPerSkill.set(loadedSkillName, new Set());
258
310
  } else {
259
- calledToolNames.add(block.name);
311
+ activeSkill = null;
260
312
  }
313
+ continue;
314
+ }
315
+
316
+ // Track non-Skill tool calls at result time (not call time) so attribution
317
+ // reflects the skill active when the result arrived, not when the call was issued.
318
+ if (activeSkill && toolName && toolName !== "Skill") {
319
+ const set = toolsCalledPerSkill.get(activeSkill);
320
+ if (set) set.add(toolName);
261
321
  }
262
322
  }
263
323
 
@@ -266,7 +326,7 @@ export async function inferBypassedSkills(streamLines, projectRoot) {
266
326
  const bypassed = [];
267
327
  for (const skillName of loadedSkills) {
268
328
  const location = findSkillLocation(skillName, projectRoot);
269
- if (!location) continue;
329
+ if (!location || location.type === "plugin-cache") continue;
270
330
  let skillMd;
271
331
  try {
272
332
  skillMd = await readFile(location.path, "utf8");
@@ -275,7 +335,8 @@ export async function inferBypassedSkills(streamLines, projectRoot) {
275
335
  }
276
336
  const mandatoryRefs = extractMandatoryMcpRefs(skillMd);
277
337
  if (mandatoryRefs.length === 0) continue;
278
- const missing = mandatoryRefs.filter((t) => !calledToolNames.has(t));
338
+ const calledForThisSkill = toolsCalledPerSkill.get(skillName) || new Set();
339
+ const missing = mandatoryRefs.filter((t) => !calledForThisSkill.has(t));
279
340
  if (missing.length === 0) continue;
280
341
  // Partial bypass also counts: even if some mandatory tools were called,
281
342
  // missing any single one of them means the skill's contract was not met.
@@ -288,7 +349,7 @@ export async function inferBypassedSkills(streamLines, projectRoot) {
288
349
  ", "
289
350
  )}] (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
351
  : `Skill "${skillName}" mandates tools [${mandatoryRefs.join(", ")}] but Claude only called [${mandatoryRefs
291
- .filter((t) => calledToolNames.has(t))
352
+ .filter((t) => calledForThisSkill.has(t))
292
353
  .join(", ")}] and skipped [${missing.join(
293
354
  ", "
294
355
  )}]. 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 +363,7 @@ function extractMandatoryMcpRefs(skillMd) {
302
363
  const mandatory = /\b(?:MUST|MANDATORY|REQUIRED|REQUIRE)\b/i;
303
364
  const mcpRef = /\bmcp__[a-zA-Z0-9_-]+__[a-zA-Z0-9_-]+/g;
304
365
  const lines = skillMd.split(/\r?\n/);
305
- // Window: line with MUST + next 4 lines. Catches "MUST call:\n\n mcp__..." patterns.
366
+ // Window: line with MUST + next 7 lines. Catches "MUST call:\n\n mcp__..." patterns.
306
367
  for (let i = 0; i < lines.length; i++) {
307
368
  if (!mandatory.test(lines[i])) continue;
308
369
  const window = lines.slice(i, Math.min(i + 8, lines.length)).join("\n");
@@ -335,21 +396,22 @@ function isSkillNotFoundError(text) {
335
396
  // each pattern below corresponds to a real error indicator, not a generic word.
336
397
  function looksLikeBashError(text) {
337
398
  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) ||
399
+ // Python tracebacks and named exceptions — require ":" to avoid matching
400
+ // class names in test output, documentation, or log messages (e.g. "0 TypeError cases").
401
+ /\bSyntaxError:/.test(text) ||
402
+ /\bNameError:/.test(text) ||
403
+ /\bTypeError:/.test(text) ||
404
+ /\bAttributeError:/.test(text) ||
405
+ /\bImportError:/.test(text) ||
406
+ /\bModuleNotFoundError:/.test(text) ||
407
+ /\bValueError:/.test(text) ||
408
+ /\bKeyError:/.test(text) ||
409
+ /\bIndexError:/.test(text) ||
410
+ /\bRuntimeError:/.test(text) ||
411
+ /\bFileNotFoundError:/.test(text) ||
412
+ /\bJSONDecodeError:/.test(text) ||
413
+ /\bOSError:/.test(text) ||
414
+ /\bAssertionError:/.test(text) ||
353
415
  /\bTraceback \(most recent call last\)/.test(text) ||
354
416
  // Node.js / npm / build tool errors
355
417
  /\bnpm\s+ERR!/.test(text) ||
@@ -360,7 +422,7 @@ function looksLikeBashError(text) {
360
422
  /\bCannot find module\b/i.test(text) ||
361
423
  /\bis not recognized as an? (?:internal or external )?command\b/i.test(text) ||
362
424
  // POSIX shell + filesystem
363
- /\bcommand not found\b/.test(text) ||
425
+ /:\s*command not found\b/.test(text) ||
364
426
  /\bNo such file or directory\b/.test(text) ||
365
427
  /\bpermission denied\b/i.test(text) ||
366
428
  /\[Errno\s+\d+\]/.test(text) ||
@@ -1,10 +1,14 @@
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";
7
+ import { promisify } from "node:util";
6
8
  import { parseRepoUrl } from "../forge/repo-host.js";
7
9
 
10
+ const execFileAsync = promisify(execFile);
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}].
10
14
  const SKILL_FIX_SCHEMA = {
@@ -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,88 @@ 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();
192
- const repo = parseRepoUrl(remoteUrl);
239
+ });
240
+ const repo = parseRepoUrl(stdout.trim());
193
241
  return { provider: repo.provider, repo };
194
242
  } catch {
195
243
  return { provider: "github", repo: null };
196
244
  }
197
245
  }
198
246
 
199
- function getDefaultBranch(projectRoot) {
247
+ async function getDefaultBranch(projectRoot) {
200
248
  // First try: symbolic-ref (instant, works when origin/HEAD is configured).
201
249
  try {
202
- return execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], {
250
+ const { stdout } = await execFileAsync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], {
203
251
  cwd: projectRoot,
204
252
  encoding: "utf8",
205
253
  timeout: 5000
206
- })
207
- .trim()
208
- .replace(/^origin\//, "");
254
+ });
255
+ return stdout.trim().replace(/^origin\//, "");
209
256
  } catch {}
210
257
  // Second try: `git remote show origin` — requires network but always reports HEAD.
211
258
  // Used when the repo was cloned with --single-branch and origin/HEAD is unset.
212
259
  try {
213
- const out = execFileSync("git", ["remote", "show", "origin"], {
260
+ const { stdout } = await execFileAsync("git", ["remote", "show", "origin"], {
214
261
  cwd: projectRoot,
215
262
  encoding: "utf8",
216
263
  timeout: 15000
217
264
  });
218
- const match = out.match(/HEAD branch:\s*(\S+)/);
265
+ const match = stdout.match(/HEAD branch:\s*(\S+)/);
219
266
  if (match?.[1] && match[1] !== "(unknown)") return match[1];
220
267
  } catch {}
268
+ // Third try: scan existing remote-tracking refs for known default names.
269
+ // Local/no-network. Handles repos cloned with --single-branch where origin/HEAD
270
+ // is unset and git remote show fails.
271
+ try {
272
+ const { stdout } = await execFileAsync("git", ["branch", "-r", "--format=%(refname:short)"], {
273
+ cwd: projectRoot,
274
+ encoding: "utf8",
275
+ timeout: 5000
276
+ });
277
+ const refs = stdout
278
+ .trim()
279
+ .split(/\r?\n/)
280
+ .map((l) => l.trim());
281
+ for (const name of ["main", "master", "develop", "trunk"]) {
282
+ if (refs.includes(`origin/${name}`)) return name;
283
+ }
284
+ } catch {}
221
285
  return "main";
222
286
  }
223
287
 
224
288
  async function createSkillFixPR({ skillName, files, reason, location, projectRoot, logger }) {
225
289
  const branch = `fix/skill-${sanitizeBranchSegment(skillName)}`;
226
- const { provider, repo } = detectProvider(projectRoot);
290
+ const { provider, repo } = await detectProvider(projectRoot);
227
291
 
228
292
  // Fail fast for GitLab if token missing — without an MR API call we cannot
229
293
  // ship the fix upstream, and writing into the active working tree would
@@ -234,14 +298,22 @@ async function createSkillFixPR({ skillName, files, reason, location, projectRoo
234
298
  return { status: "error", error: msg };
235
299
  }
236
300
 
237
- // Idempotency: skip if PR/MR already open
301
+ // Idempotency: skip if PR/MR already open.
302
+ // null = API unreachable; log and proceed rather than silently skipping (risk of
303
+ // duplicate is low; failing to fix would be worse than a duplicate PR).
238
304
  const existing = await findExistingPR({ branch, provider, repo, projectRoot });
239
- if (existing) {
305
+ if (existing === true) {
240
306
  logger?.info?.("skill-fix: PR/MR already open, skipping", { skill: skillName, branch });
241
307
  return { status: "pr-exists" };
242
308
  }
309
+ if (existing === null) {
310
+ logger?.warn?.("skill-fix: could not check for existing PR/MR (API error), proceeding", {
311
+ skill: skillName,
312
+ branch
313
+ });
314
+ }
243
315
 
244
- const defaultBranch = getDefaultBranch(projectRoot);
316
+ const defaultBranch = await getDefaultBranch(projectRoot);
245
317
  const safeReason = reason.slice(0, 250);
246
318
  const commitMsg = `fix(skill): auto-patch ${skillName}\n\n${safeReason}`;
247
319
  const prTitle = `fix(skill): auto-patch ${skillName}`;
@@ -255,18 +327,18 @@ async function createSkillFixPR({ skillName, files, reason, location, projectRoo
255
327
  try {
256
328
  // Pull latest default branch so the fix is based on current HEAD, not stale local state.
257
329
  try {
258
- execFileSync("git", ["fetch", "origin", defaultBranch], { cwd: projectRoot, timeout: 60000 });
330
+ await execFileAsync("git", ["fetch", "origin", defaultBranch], { cwd: projectRoot, timeout: 60000 });
259
331
  } catch {}
260
332
 
261
333
  // Prune stale worktree registrations left by prior SIGKILL — otherwise
262
334
  // `git worktree add -B branch` fails if the branch is still "checked out"
263
335
  // at a now-deleted tmp path.
264
336
  try {
265
- execFileSync("git", ["worktree", "prune"], { cwd: projectRoot, timeout: 10000 });
337
+ await execFileAsync("git", ["worktree", "prune"], { cwd: projectRoot, timeout: 10000 });
266
338
  } catch {}
267
339
 
268
340
  // -B: create or reset branch to point at origin/<defaultBranch>
269
- execFileSync("git", ["worktree", "add", "-B", branch, worktreePath, `origin/${defaultBranch}`], {
341
+ await execFileAsync("git", ["worktree", "add", "-B", branch, worktreePath, `origin/${defaultBranch}`], {
270
342
  cwd: projectRoot,
271
343
  timeout: 15000
272
344
  });
@@ -281,34 +353,54 @@ async function createSkillFixPR({ skillName, files, reason, location, projectRoo
281
353
  await mkdir(worktreeSkillDir, { recursive: true });
282
354
  await writeSkillFiles(files, worktreeSkillDir);
283
355
 
284
- execFileSync("git", ["add", skillRelDir], { cwd: worktreePath, timeout: 10000 });
356
+ await execFileAsync("git", ["add", skillRelDir], { cwd: worktreePath, timeout: 10000 });
285
357
 
286
358
  // No-op fix detection: nothing actually changed → skip empty commit/PR.
287
- const diffStatus = execFileSync("git", ["diff", "--cached", "--name-only"], {
359
+ const { stdout: diffStatus } = await execFileAsync("git", ["diff", "--cached", "--name-only"], {
288
360
  cwd: worktreePath,
289
361
  encoding: "utf8",
290
362
  timeout: 10000
291
- }).trim();
292
- if (!diffStatus) {
363
+ });
364
+ if (!diffStatus.trim()) {
293
365
  logger?.info?.("skill-fix: no file changes after generation, skipping PR", { skill: skillName });
294
366
  return { status: "no-op" };
295
367
  }
296
368
 
297
- execFileSync("git", ["commit", "-m", commitMsg], { cwd: worktreePath, timeout: 10000 });
369
+ const changedFileCount = diffStatus.trim().split("\n").filter(Boolean).length;
370
+ // Inject git identity so commit succeeds in containers where user.name/email are unset.
371
+ await execFileAsync(
372
+ "git",
373
+ [
374
+ "-c",
375
+ "user.email=skill-fixer@claude-teammate.local",
376
+ "-c",
377
+ "user.name=Claude Teammate",
378
+ "commit",
379
+ "-m",
380
+ commitMsg
381
+ ],
382
+ { cwd: worktreePath, timeout: 10000 }
383
+ );
298
384
  // --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 });
385
+ await execFileAsync("git", ["push", "--force-with-lease", "-u", "origin", branch], {
386
+ cwd: worktreePath,
387
+ timeout: 60000
388
+ });
300
389
 
301
390
  const prUrl = await openPR({ branch, prTitle, safeReason, defaultBranch, provider, repo, projectRoot });
302
391
 
303
- logger?.info?.("skill-fix: PR/MR created", { skill: skillName, pr: prUrl });
304
- return { status: "pr-created", prUrl };
392
+ logger?.info?.("skill-fix: PR/MR created", { skill: skillName, pr: prUrl, filesWritten: changedFileCount });
393
+ return { status: "pr-created", prUrl, filesWritten: changedFileCount };
305
394
  } catch (err) {
306
395
  logger?.error?.("skill-fix: PR/MR creation failed", { skill: skillName, error: err?.message });
307
396
  return { status: "error", error: err?.message };
308
397
  } finally {
309
398
  if (worktreeAttached) {
310
399
  try {
311
- execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: projectRoot, timeout: 10000 });
400
+ await execFileAsync("git", ["worktree", "remove", "--force", worktreePath], {
401
+ cwd: projectRoot,
402
+ timeout: 10000
403
+ });
312
404
  } catch {}
313
405
  }
314
406
  try {
@@ -317,6 +409,8 @@ async function createSkillFixPR({ skillName, files, reason, location, projectRoo
317
409
  }
318
410
  }
319
411
 
412
+ // Returns true (PR exists), false (no PR), or null (API call failed — caller should proceed
413
+ // with caution and log a warning rather than treating as "no PR").
320
414
  async function findExistingPR({ branch, provider, repo, projectRoot }) {
321
415
  try {
322
416
  if (provider === "gitlab" && repo) {
@@ -324,23 +418,27 @@ async function findExistingPR({ branch, provider, repo, projectRoot }) {
324
418
  if (!token) return false;
325
419
  const projectId = encodeURIComponent(repo.path);
326
420
  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);
421
+ const { stdout } = await execFileAsync(
422
+ "curl",
423
+ ["-sf", "--max-time", "30", "-H", `PRIVATE-TOKEN: ${token}`, url],
424
+ { encoding: "utf8", timeout: 35000 }
425
+ );
426
+ const parsed = JSON.parse(stdout);
332
427
  if (parsed?.message) throw new Error(`GitLab API error: ${parsed.message}`);
333
428
  return Array.isArray(parsed) && parsed.length > 0;
334
429
  }
335
430
  // GitHub
336
- const out = execFileSync(
431
+ const { stdout } = await execFileAsync(
337
432
  "gh",
338
433
  ["pr", "list", "--head", branch, "--state", "open", "--json", "number", "--jq", "length"],
339
434
  { cwd: projectRoot, encoding: "utf8", timeout: 30000 }
340
- ).trim();
435
+ );
436
+ const out = stdout.trim();
341
437
  return out !== "0" && out !== "";
342
438
  } catch {
343
- return false;
439
+ // Return null so the caller can distinguish "no PR found" from "API unavailable".
440
+ // Callers must not silently treat this as "no PR" to avoid creating duplicates.
441
+ return null;
344
442
  }
345
443
  }
346
444
 
@@ -357,7 +455,7 @@ async function openPR({ branch, prTitle, safeReason, defaultBranch, provider, re
357
455
  description: safeReason,
358
456
  remove_source_branch: true
359
457
  });
360
- const out = execFileSync(
458
+ const { stdout } = await execFileAsync(
361
459
  "curl",
362
460
  [
363
461
  "-sf",
@@ -375,13 +473,13 @@ async function openPR({ branch, prTitle, safeReason, defaultBranch, provider, re
375
473
  ],
376
474
  { encoding: "utf8", timeout: 35000 }
377
475
  );
378
- const parsed = JSON.parse(out);
476
+ const parsed = JSON.parse(stdout);
379
477
  if (parsed?.message) throw new Error(`GitLab API error: ${parsed.message}`);
380
478
  return parsed.web_url || url;
381
479
  }
382
480
 
383
481
  // GitHub — `gh pr create` may print warnings before the URL; take the last non-empty line.
384
- const ghOut = execFileSync(
482
+ const { stdout: ghOut } = await execFileAsync(
385
483
  "gh",
386
484
  ["pr", "create", "--title", prTitle, "--body", safeReason, "--base", defaultBranch],
387
485
  { cwd: projectRoot, encoding: "utf8", timeout: 30000 }
@@ -77,6 +77,13 @@ async function _detectAndFix({
77
77
  ];
78
78
  if (failures.length === 0) return;
79
79
 
80
+ if (!eventsRoot) {
81
+ logger?.warn?.(
82
+ "skill-fix: eventsRoot not provided — events will be written to projectRoot; pass eventsRoot to avoid writing skill-fixes.json into the cloned repo",
83
+ { projectRoot }
84
+ );
85
+ }
86
+
80
87
  await fixSkillsAsync(
81
88
  failures,
82
89
  projectRoot,
@@ -95,7 +102,13 @@ async function fixSkillsAsync(failures, projectRoot, eventsRoot, logger, invokeC
95
102
  // single fix run so the model sees the full failure picture.
96
103
  const grouped = new Map();
97
104
  for (const f of failures) {
98
- if (!f?.skillName || f.skillName === "unknown") continue;
105
+ if (!f?.skillName || f.skillName === "unknown") {
106
+ logger?.warn?.(
107
+ "skill-fix: skill name resolved to 'unknown' — Skill tool input format may have changed; check block.input key names",
108
+ { failure: f }
109
+ );
110
+ continue;
111
+ }
99
112
  const existing = grouped.get(f.skillName);
100
113
  if (!existing) {
101
114
  grouped.set(f.skillName, { ...f });
@@ -202,6 +215,11 @@ async function fixSingleSkill({
202
215
  await appendSkillFixEvent(eventsRoot, { skill: skillName, errorType, status: "not-found" });
203
216
  return;
204
217
  }
218
+ if (location.type === "plugin-cache") {
219
+ logger?.info("skill-fix: plugin-cache skill is out of scope for auto-fix", { skill: skillName });
220
+ await appendSkillFixEvent(eventsRoot, { skill: skillName, errorType, status: "scope-excluded" });
221
+ return;
222
+ }
205
223
 
206
224
  const skillFiles = await readSkillFiles(location.dir);
207
225
  if (skillFiles.length === 0) {
@@ -251,6 +269,15 @@ async function fixSingleSkill({
251
269
  return;
252
270
  }
253
271
 
272
+ // Ensure the model returned ALL files: if any input file is absent from the fix
273
+ // output, preserve its original content so we never silently drop skill files.
274
+ const returnedPaths = new Set(fix.files.map((f) => f.path));
275
+ for (const original of skillFiles) {
276
+ if (!returnedPaths.has(original.path)) {
277
+ fix.files.push({ path: original.path, content: original.content });
278
+ }
279
+ }
280
+
254
281
  const result = await applySkillFix({
255
282
  skillName,
256
283
  files: fix.files,
@@ -268,16 +295,16 @@ async function fixSingleSkill({
268
295
  status: result.status,
269
296
  prUrl: result.prUrl,
270
297
  error: result.error,
271
- files: fix.files.length
298
+ files: result.filesWritten ?? fix.files.length
272
299
  });
273
300
  }
274
301
 
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.
302
+ // In-process mutex for skill-fixes.json, keyed per eventsRoot so different roots
303
+ // don't serialize against each other (relevant in tests and future multi-tenant use).
304
+ // The file is read-modify-written; chaining all writes through one promise per path
305
+ // eliminates interleaved-read races within this process.
279
306
  // Multi-process deployments would need a file lock (flock); not applicable here.
280
- let _eventsWriteChain = Promise.resolve();
307
+ const _eventsWriteChains = new Map();
281
308
 
282
309
  async function appendSkillFixEvent(eventsRoot, fields) {
283
310
  if (!eventsRoot) return;
@@ -297,9 +324,14 @@ async function appendSkillFixEvent(eventsRoot, fields) {
297
324
  // never throw — event logging is best-effort
298
325
  }
299
326
  };
300
- // Chain after previous job; .catch keeps chain alive on rejection.
301
- const next = _eventsWriteChain.then(job, job);
302
- _eventsWriteChain = next.catch(() => {});
327
+ const prev = _eventsWriteChains.get(eventsRoot) || Promise.resolve();
328
+ const next = prev.then(job, job);
329
+ const queued = next.catch(() => {});
330
+ _eventsWriteChains.set(eventsRoot, queued);
331
+ // Remove key once settled so the Map doesn't grow unboundedly.
332
+ queued.finally(() => {
333
+ if (_eventsWriteChains.get(eventsRoot) === queued) _eventsWriteChains.delete(eventsRoot);
334
+ });
303
335
  return next;
304
336
  }
305
337
 
@@ -16,7 +16,8 @@ export function findSkillLocation(skillName, projectRoot) {
16
16
  const raw = String(skillName || "")
17
17
  .replace(/^\//, "")
18
18
  .trim();
19
- const base = raw.includes(":") ? raw.split(":").pop() : raw;
19
+ const isPluginPrefixed = raw.includes(":");
20
+ const base = isPluginPrefixed ? raw.split(":").pop() : raw;
20
21
  // Reject empty, dotfiles, or path traversal attempts
21
22
  if (!base || base.startsWith(".") || base.includes("/") || base.includes("\\")) return null;
22
23
 
@@ -32,7 +33,13 @@ export function findSkillLocation(skillName, projectRoot) {
32
33
  const resolved = path.resolve(c.path);
33
34
  return resolved.startsWith(path.resolve(c.root)) && existsSync(c.path);
34
35
  });
35
- if (!found) return null;
36
+ if (!found) {
37
+ // Plugin-namespaced skills (e.g. "caveman:caveman", "plugin:figma:figma-use") that are
38
+ // not overridden by a repo or global skill are owned by their plugin author and
39
+ // intentionally out of scope for auto-fix. Return a distinct marker so callers can
40
+ // log "scope-excluded" instead of the misleading "not-found".
41
+ return isPluginPrefixed ? { type: "plugin-cache", path: null, dir: null } : null;
42
+ }
36
43
  // dir: skill directory containing SKILL.md and any companion scripts
37
44
  return { ...found, dir: path.dirname(found.path) };
38
45
  }