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 +1 -1
- package/src/claude/stream.js +10 -0
- package/src/claude.js +5 -1
- package/src/skills/detector.js +99 -32
- package/src/skills/fixer.js +173 -51
- package/src/skills/index.js +64 -16
- package/src/skills/locator.js +24 -9
- package/src/worker/jira-issue-workflow.js +7 -2
package/package.json
CHANGED
package/src/claude/stream.js
CHANGED
|
@@ -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,
|
|
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,
|
package/src/skills/detector.js
CHANGED
|
@@ -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
|
-
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
340
|
-
/\
|
|
341
|
-
/\
|
|
342
|
-
/\
|
|
343
|
-
/\
|
|
344
|
-
/\
|
|
345
|
-
/\
|
|
346
|
-
/\
|
|
347
|
-
/\
|
|
348
|
-
/\
|
|
349
|
-
/\
|
|
350
|
-
/\
|
|
351
|
-
/\
|
|
352
|
-
/\
|
|
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
|
-
|
|
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) ||
|
package/src/skills/fixer.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
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";
|
|
6
|
-
import {
|
|
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 =
|
|
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,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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
382
|
+
const { stdout: diffStatus } = await execFileAsync("git", ["diff", "--cached", "--name-only"], {
|
|
288
383
|
cwd: worktreePath,
|
|
289
384
|
encoding: "utf8",
|
|
290
385
|
timeout: 10000
|
|
291
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
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
|
-
)
|
|
459
|
+
);
|
|
460
|
+
const out = stdout.trim();
|
|
341
461
|
return out !== "0" && out !== "";
|
|
342
462
|
} catch {
|
|
343
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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 }
|
package/src/skills/index.js
CHANGED
|
@@ -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 {
|
|
6
|
+
import { findSkillLocationInRoots } from "./locator.js";
|
|
6
7
|
|
|
7
|
-
const SKILL_FIX_EVENTS_MAX =
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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", {
|
|
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
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
const next =
|
|
302
|
-
|
|
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
|
|
package/src/skills/locator.js
CHANGED
|
@@ -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
|
|
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
|
|
20
|
-
|
|
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(
|
|
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)
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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,
|