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 +1 -1
- package/src/claude.js +1 -1
- package/src/skills/detector.js +94 -32
- package/src/skills/fixer.js +146 -48
- package/src/skills/index.js +42 -10
- package/src/skills/locator.js +9 -2
package/package.json
CHANGED
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,
|
|
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,
|
package/src/skills/detector.js
CHANGED
|
@@ -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
|
-
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
340
|
-
/\
|
|
341
|
-
/\
|
|
342
|
-
/\
|
|
343
|
-
/\
|
|
344
|
-
/\
|
|
345
|
-
/\
|
|
346
|
-
/\
|
|
347
|
-
/\
|
|
348
|
-
/\
|
|
349
|
-
/\
|
|
350
|
-
/\
|
|
351
|
-
/\
|
|
352
|
-
/\
|
|
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
|
-
|
|
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) ||
|
package/src/skills/fixer.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
61
|
-
|
|
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 (
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
|
235
|
+
const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], {
|
|
188
236
|
cwd: projectRoot,
|
|
189
237
|
encoding: "utf8",
|
|
190
238
|
timeout: 5000
|
|
191
|
-
})
|
|
192
|
-
const repo = parseRepoUrl(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
359
|
+
const { stdout: diffStatus } = await execFileAsync("git", ["diff", "--cached", "--name-only"], {
|
|
288
360
|
cwd: worktreePath,
|
|
289
361
|
encoding: "utf8",
|
|
290
362
|
timeout: 10000
|
|
291
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
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
|
-
)
|
|
435
|
+
);
|
|
436
|
+
const out = stdout.trim();
|
|
341
437
|
return out !== "0" && out !== "";
|
|
342
438
|
} catch {
|
|
343
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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 }
|
package/src/skills/index.js
CHANGED
|
@@ -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")
|
|
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
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
const next =
|
|
302
|
-
|
|
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
|
|
package/src/skills/locator.js
CHANGED
|
@@ -16,7 +16,8 @@ export function findSkillLocation(skillName, projectRoot) {
|
|
|
16
16
|
const raw = String(skillName || "")
|
|
17
17
|
.replace(/^\//, "")
|
|
18
18
|
.trim();
|
|
19
|
-
const
|
|
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)
|
|
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
|
}
|