@opengoat/core 2026.2.20 → 2026.2.23

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.
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { BOARD_MANAGER_SKILL_ID } from "../../agents/domain/agent-manifest.js";
@@ -6,9 +7,11 @@ import { resolveSkillsConfig, } from "../domain/skill.js";
6
7
  export class SkillService {
7
8
  fileSystem;
8
9
  pathPort;
10
+ commandRunner;
9
11
  constructor(deps) {
10
12
  this.fileSystem = deps.fileSystem;
11
13
  this.pathPort = deps.pathPort;
14
+ this.commandRunner = deps.commandRunner;
12
15
  }
13
16
  async listSkills(paths, agentId = DEFAULT_AGENT_ID, runtimeConfig) {
14
17
  const normalizedAgentId = normalizeAgentId(agentId) || DEFAULT_AGENT_ID;
@@ -17,7 +20,7 @@ export class SkillService {
17
20
  if (!resolvedConfig.enabled) {
18
21
  return [];
19
22
  }
20
- const discovered = await this.loadSkillsWithPrecedence(paths, resolvedConfig);
23
+ const discovered = await this.loadSkillsWithPrecedence(paths, normalizedAgentId, resolvedConfig);
21
24
  const assigned = new Set(resolvedConfig.assigned);
22
25
  const filtered = assigned.size > 0
23
26
  ? discovered.filter((skill) => assigned.has(skill.id))
@@ -64,7 +67,7 @@ export class SkillService {
64
67
  "- If exactly one skill clearly applies, follow that skill.",
65
68
  "- If multiple skills could apply, choose the most specific.",
66
69
  "- If none apply, continue without using a skill.",
67
- "Skill definitions are centralized under the global skills store.",
70
+ "Skill definitions may come from global and agent-specific stores.",
68
71
  "",
69
72
  ];
70
73
  if (selected.length === 0) {
@@ -95,75 +98,353 @@ export class SkillService {
95
98
  skills: selected,
96
99
  };
97
100
  }
98
- async installSkill(paths, request) {
101
+ async installSkill(paths, request, options = {}) {
102
+ const hasSourcePath = Boolean(request.sourcePath?.trim());
103
+ const hasSourceUrl = Boolean(request.sourceUrl?.trim());
104
+ if (hasSourcePath && hasSourceUrl) {
105
+ throw new Error("Use either sourcePath or sourceUrl, not both.");
106
+ }
99
107
  const scope = request.scope === "global" ? "global" : "agent";
100
108
  const normalizedAgentId = normalizeAgentId(request.agentId ?? DEFAULT_AGENT_ID) || DEFAULT_AGENT_ID;
101
- const skillId = normalizeAgentId(request.skillName);
109
+ const requestedSkillName = request.skillName?.trim() || request.sourceSkillName?.trim();
110
+ const skillId = normalizeAgentId(requestedSkillName ?? "");
102
111
  if (!skillId) {
103
112
  throw new Error("Skill name must contain at least one alphanumeric character.");
104
113
  }
105
- const baseDir = paths.skillsDir;
106
- const targetDir = this.pathPort.join(baseDir, skillId);
114
+ const globalSkillDir = this.pathPort.join(paths.skillsDir, skillId);
115
+ const globalSkillFile = this.pathPort.join(globalSkillDir, "SKILL.md");
116
+ const agentScopedSkillsBaseDir = this.pathPort.join(paths.skillsDir, AGENT_SCOPED_STORE_DIR, normalizedAgentId);
117
+ const agentScopedSkillDir = this.pathPort.join(agentScopedSkillsBaseDir, skillId);
118
+ const targetDir = scope === "global" ? globalSkillDir : agentScopedSkillDir;
107
119
  const targetSkillFile = this.pathPort.join(targetDir, "SKILL.md");
108
- const replaced = await this.fileSystem.exists(targetDir);
109
- await this.fileSystem.ensureDir(baseDir);
110
- if (request.sourcePath?.trim()) {
111
- const sourcePath = resolveUserPath(request.sourcePath.trim());
112
- const sourceSkillFile = sourcePath.toLowerCase().endsWith("/skill.md") ||
113
- sourcePath.toLowerCase().endsWith("\\skill.md")
114
- ? sourcePath
115
- : this.pathPort.join(sourcePath, "SKILL.md");
116
- const sourceFileExists = await this.fileSystem.exists(sourceSkillFile);
117
- if (!sourceFileExists) {
118
- throw new Error(`Source skill not found: ${sourceSkillFile}`);
120
+ let replaced = await this.fileSystem.exists(targetDir);
121
+ let workspaceInstallPaths = [];
122
+ if (hasSourcePath || hasSourceUrl) {
123
+ await this.fileSystem.ensureDir(scope === "global" ? paths.skillsDir : agentScopedSkillsBaseDir);
124
+ let resolvedSource;
125
+ try {
126
+ resolvedSource = hasSourcePath
127
+ ? await this.resolveInstallSourceFromPath(request.sourcePath?.trim() ?? "")
128
+ : await this.resolveInstallSourceFromUrl(paths, request.sourceUrl?.trim() ?? "", request.sourceSkillName, skillId);
129
+ await this.fileSystem.removeDir(targetDir);
130
+ await this.fileSystem.copyDir(resolvedSource.sourceDir, targetDir);
131
+ }
132
+ finally {
133
+ if (resolvedSource?.cleanup) {
134
+ await resolvedSource.cleanup();
135
+ }
119
136
  }
120
- const sourceDir = sourceSkillFile === sourcePath ? path.dirname(sourcePath) : sourcePath;
121
- await this.fileSystem.removeDir(targetDir);
122
- await this.fileSystem.copyDir(sourceDir, targetDir);
123
137
  if (scope === "agent") {
124
- await this.assignSkillToAgent(paths, normalizedAgentId, skillId);
125
- await this.reconcileRoleSkillsIfNeeded(paths, normalizedAgentId, skillId);
138
+ workspaceInstallPaths = await this.installSkillForAgent(paths, normalizedAgentId, skillId, options, targetDir);
126
139
  }
127
140
  return {
128
141
  scope,
129
142
  agentId: scope === "agent" ? normalizedAgentId : undefined,
143
+ assignedAgentIds: scope === "agent" ? [normalizedAgentId] : undefined,
130
144
  skillId,
131
- skillName: request.skillName.trim(),
132
- source: "source-path",
145
+ skillName: requestedSkillName ?? skillId,
146
+ source: hasSourcePath ? "source-path" : "source-url",
133
147
  installedPath: targetSkillFile,
148
+ workspaceInstallPaths,
134
149
  replaced,
135
150
  };
136
151
  }
137
152
  let source = "generated";
138
- const existingGlobalSkill = this.pathPort.join(paths.skillsDir, skillId, "SKILL.md");
139
- if (scope === "agent" &&
140
- (await this.fileSystem.exists(existingGlobalSkill))) {
153
+ let installedPath = targetSkillFile;
154
+ let workspaceSourceDir = targetDir;
155
+ if (scope === "agent" && (await this.fileSystem.exists(globalSkillFile))) {
156
+ const hadAgentOverride = await this.fileSystem.exists(agentScopedSkillDir);
157
+ if (hadAgentOverride) {
158
+ await this.fileSystem.removeDir(agentScopedSkillDir);
159
+ }
160
+ replaced = hadAgentOverride;
141
161
  source = "managed";
162
+ installedPath = globalSkillFile;
163
+ workspaceSourceDir = globalSkillDir;
142
164
  }
143
165
  else {
144
166
  const description = request.description?.trim() ||
145
- `Skill instructions for ${request.skillName.trim()}.`;
167
+ `Skill instructions for ${requestedSkillName ?? skillId}.`;
146
168
  const content = request.content?.trim() ||
147
169
  renderSkillMarkdown({ skillId, description });
170
+ await this.fileSystem.ensureDir(scope === "global" ? paths.skillsDir : agentScopedSkillsBaseDir);
148
171
  await this.fileSystem.ensureDir(targetDir);
149
172
  await this.fileSystem.writeFile(targetSkillFile, ensureTrailingNewline(content));
150
173
  source = "generated";
151
174
  }
152
175
  if (scope === "agent") {
153
- await this.assignSkillToAgent(paths, normalizedAgentId, skillId);
154
- await this.reconcileRoleSkillsIfNeeded(paths, normalizedAgentId, skillId);
176
+ workspaceInstallPaths = await this.installSkillForAgent(paths, normalizedAgentId, skillId, options, workspaceSourceDir);
155
177
  }
156
178
  return {
157
179
  scope,
158
180
  agentId: scope === "agent" ? normalizedAgentId : undefined,
181
+ assignedAgentIds: scope === "agent" ? [normalizedAgentId] : undefined,
159
182
  skillId,
160
- skillName: request.skillName.trim(),
183
+ skillName: requestedSkillName ?? skillId,
161
184
  source,
162
- installedPath: targetSkillFile,
185
+ installedPath,
186
+ workspaceInstallPaths,
163
187
  replaced,
164
188
  };
165
189
  }
166
- async loadSkillsWithPrecedence(paths, config) {
190
+ async assignInstalledSkillToAgent(paths, agentId, skillId, options = {}) {
191
+ const normalizedAgentId = normalizeAgentId(agentId) || DEFAULT_AGENT_ID;
192
+ const normalizedSkillId = normalizeAgentId(skillId);
193
+ if (!normalizedSkillId) {
194
+ throw new Error("Skill id must contain at least one alphanumeric character.");
195
+ }
196
+ const sourceSkillFile = this.pathPort.join(paths.skillsDir, normalizedSkillId, "SKILL.md");
197
+ if (!(await this.fileSystem.exists(sourceSkillFile))) {
198
+ throw new Error(`Skill "${normalizedSkillId}" is not installed in global storage.`);
199
+ }
200
+ return this.installSkillForAgent(paths, normalizedAgentId, normalizedSkillId, options, this.pathPort.join(paths.skillsDir, normalizedSkillId));
201
+ }
202
+ async removeSkill(paths, request, options = {}) {
203
+ const scope = request.scope === "global" ? "global" : "agent";
204
+ const normalizedSkillId = normalizeAgentId(request.skillId ?? "");
205
+ if (!normalizedSkillId) {
206
+ throw new Error("Skill id must contain at least one alphanumeric character.");
207
+ }
208
+ if (scope === "global") {
209
+ const globalSkillDir = this.pathPort.join(paths.skillsDir, normalizedSkillId);
210
+ const removedFromGlobal = await this.fileSystem.exists(globalSkillDir);
211
+ await this.fileSystem.removeDir(globalSkillDir);
212
+ return {
213
+ scope: "global",
214
+ skillId: normalizedSkillId,
215
+ removedFromGlobal,
216
+ removedFromAgentIds: [],
217
+ removedWorkspacePaths: [],
218
+ };
219
+ }
220
+ const normalizedAgentId = normalizeAgentId(request.agentId ?? DEFAULT_AGENT_ID) || DEFAULT_AGENT_ID;
221
+ const removed = await this.removeSkillForAgent(paths, normalizedAgentId, normalizedSkillId, options);
222
+ return {
223
+ scope: "agent",
224
+ agentId: normalizedAgentId,
225
+ skillId: normalizedSkillId,
226
+ removedFromGlobal: false,
227
+ removedFromAgentIds: removed.removedFromConfig ||
228
+ removed.removedFromWorkspace ||
229
+ removed.removedFromAgentStore
230
+ ? [normalizedAgentId]
231
+ : [],
232
+ removedWorkspacePaths: removed.removedWorkspacePaths,
233
+ };
234
+ }
235
+ async removeAssignedSkillFromAgent(paths, agentId, skillId, options = {}) {
236
+ const normalizedAgentId = normalizeAgentId(agentId) || DEFAULT_AGENT_ID;
237
+ const normalizedSkillId = normalizeAgentId(skillId ?? "");
238
+ if (!normalizedSkillId) {
239
+ throw new Error("Skill id must contain at least one alphanumeric character.");
240
+ }
241
+ return this.removeSkillForAgent(paths, normalizedAgentId, normalizedSkillId, options);
242
+ }
243
+ async installSkillForAgent(paths, agentId, skillId, options, sourceSkillDir) {
244
+ await this.assignSkillToAgent(paths, agentId, skillId);
245
+ await this.reconcileRoleSkillsIfNeeded(paths, agentId, skillId);
246
+ return this.syncSkillToWorkspace(paths, agentId, skillId, options, sourceSkillDir);
247
+ }
248
+ async removeSkillForAgent(paths, agentId, skillId, options) {
249
+ const removedFromAgentStore = await this.removeAgentScopedSkill(paths, agentId, skillId);
250
+ const removedFromConfig = await this.unassignSkillFromAgent(paths, agentId, skillId);
251
+ const removedWorkspacePaths = await this.removeSkillFromWorkspace(paths, agentId, skillId, options);
252
+ return {
253
+ removedFromConfig,
254
+ removedFromAgentStore,
255
+ removedFromWorkspace: removedWorkspacePaths.length > 0,
256
+ removedWorkspacePaths,
257
+ };
258
+ }
259
+ async syncSkillToWorkspace(paths, agentId, skillId, options, sourceSkillDir) {
260
+ const workspaceDir = options.workspaceDir?.trim() ||
261
+ this.pathPort.join(paths.workspacesDir, agentId);
262
+ const normalizedDirectories = normalizeWorkspaceSkillDirectories(options.workspaceSkillDirectories);
263
+ if (normalizedDirectories.length === 0) {
264
+ return [];
265
+ }
266
+ const resolvedSourceDir = sourceSkillDir ??
267
+ (await this.resolveInstalledSkillSourceDir(paths, agentId, skillId));
268
+ if (!resolvedSourceDir ||
269
+ !(await this.fileSystem.exists(resolvedSourceDir))) {
270
+ return [];
271
+ }
272
+ const installedPaths = [];
273
+ for (const relativeSkillsDir of normalizedDirectories) {
274
+ const skillsDir = this.pathPort.join(workspaceDir, relativeSkillsDir);
275
+ const targetSkillDir = this.pathPort.join(skillsDir, skillId);
276
+ await this.fileSystem.ensureDir(skillsDir);
277
+ await this.fileSystem.removeDir(targetSkillDir);
278
+ await this.fileSystem.copyDir(resolvedSourceDir, targetSkillDir);
279
+ installedPaths.push(this.pathPort.join(targetSkillDir, "SKILL.md"));
280
+ }
281
+ return installedPaths;
282
+ }
283
+ async removeSkillFromWorkspace(paths, agentId, skillId, options) {
284
+ const workspaceDir = options.workspaceDir?.trim() ||
285
+ this.pathPort.join(paths.workspacesDir, agentId);
286
+ const normalizedDirectories = normalizeWorkspaceSkillDirectories(options.workspaceSkillDirectories);
287
+ if (normalizedDirectories.length === 0) {
288
+ return [];
289
+ }
290
+ const removedPaths = [];
291
+ for (const relativeSkillsDir of normalizedDirectories) {
292
+ const skillsDir = this.pathPort.join(workspaceDir, relativeSkillsDir);
293
+ const targetSkillDir = this.pathPort.join(skillsDir, skillId);
294
+ if (!(await this.fileSystem.exists(targetSkillDir))) {
295
+ continue;
296
+ }
297
+ await this.fileSystem.removeDir(targetSkillDir);
298
+ removedPaths.push(this.pathPort.join(targetSkillDir, "SKILL.md"));
299
+ }
300
+ return removedPaths;
301
+ }
302
+ async resolveInstalledSkillSourceDir(paths, agentId, skillId) {
303
+ const agentScopedDir = this.pathPort.join(paths.skillsDir, AGENT_SCOPED_STORE_DIR, agentId, skillId);
304
+ if (await this.fileSystem.exists(agentScopedDir)) {
305
+ return agentScopedDir;
306
+ }
307
+ const globalDir = this.pathPort.join(paths.skillsDir, skillId);
308
+ if (await this.fileSystem.exists(globalDir)) {
309
+ return globalDir;
310
+ }
311
+ return undefined;
312
+ }
313
+ async removeAgentScopedSkill(paths, agentId, skillId) {
314
+ const agentScopedDir = this.pathPort.join(paths.skillsDir, AGENT_SCOPED_STORE_DIR, agentId, skillId);
315
+ const exists = await this.fileSystem.exists(agentScopedDir);
316
+ if (!exists) {
317
+ return false;
318
+ }
319
+ await this.fileSystem.removeDir(agentScopedDir);
320
+ return true;
321
+ }
322
+ async resolveInstallSourceFromPath(sourcePathInput) {
323
+ const sourcePath = resolveUserPath(sourcePathInput);
324
+ const sourceSkillFile = sourcePath.toLowerCase().endsWith("/skill.md") ||
325
+ sourcePath.toLowerCase().endsWith("\\skill.md")
326
+ ? sourcePath
327
+ : this.pathPort.join(sourcePath, "SKILL.md");
328
+ const sourceFileExists = await this.fileSystem.exists(sourceSkillFile);
329
+ if (!sourceFileExists) {
330
+ throw new Error(`Source skill not found: ${sourceSkillFile}`);
331
+ }
332
+ const sourceDir = sourceSkillFile === sourcePath ? path.dirname(sourcePath) : sourcePath;
333
+ return {
334
+ sourceDir,
335
+ source: "source-path",
336
+ };
337
+ }
338
+ async resolveInstallSourceFromUrl(paths, sourceUrl, sourceSkillName, fallbackSkillId) {
339
+ if (!this.commandRunner) {
340
+ throw new Error("Installing skills from URL requires a configured command runner.");
341
+ }
342
+ const resolvedUrl = sourceUrl.trim();
343
+ if (!resolvedUrl) {
344
+ throw new Error("sourceUrl cannot be empty.");
345
+ }
346
+ const tempRoot = this.pathPort.join(paths.homeDir, ".tmp", "skill-installs", randomUUID());
347
+ const cloneDir = this.pathPort.join(tempRoot, "repo");
348
+ await this.fileSystem.ensureDir(tempRoot);
349
+ const cloneSource = stripGitHubTreePath(resolvedUrl);
350
+ const cloneResult = await this.commandRunner.run({
351
+ command: "git",
352
+ args: ["clone", "--depth", "1", cloneSource, cloneDir],
353
+ cwd: paths.homeDir,
354
+ });
355
+ if (cloneResult.code !== 0) {
356
+ throw new Error(`Unable to clone skill source URL "${resolvedUrl}". ${cloneResult.stderr.trim() || cloneResult.stdout.trim() || ""}`.trim());
357
+ }
358
+ const pathHint = resolveGitHubTreePathHint(resolvedUrl);
359
+ const skillHint = sourceSkillName?.trim() || fallbackSkillId;
360
+ const resolvedSkillDir = await this.resolveRepositorySkillDirectory(cloneDir, skillHint, pathHint);
361
+ return {
362
+ sourceDir: resolvedSkillDir,
363
+ source: "source-url",
364
+ cleanup: async () => {
365
+ await this.fileSystem.removeDir(tempRoot);
366
+ },
367
+ };
368
+ }
369
+ async resolveRepositorySkillDirectory(repositoryDir, skillHint, pathHint) {
370
+ const normalizedSkillHint = normalizeAgentId(skillHint);
371
+ const normalizedPathHint = normalizeRelativeDirectory(pathHint);
372
+ if (normalizedPathHint) {
373
+ const hinted = this.pathPort.join(repositoryDir, normalizedPathHint);
374
+ const hintedSkillFile = this.pathPort.join(hinted, "SKILL.md");
375
+ if (await this.fileSystem.exists(hintedSkillFile)) {
376
+ return hinted;
377
+ }
378
+ }
379
+ if (normalizedSkillHint) {
380
+ const candidateRelativePaths = [
381
+ normalizedSkillHint,
382
+ `skills/${normalizedSkillHint}`,
383
+ `.claude/skills/${normalizedSkillHint}`,
384
+ ".claude-plugin/skills/" + normalizedSkillHint,
385
+ ".agents/skills/" + normalizedSkillHint,
386
+ ".agent/skills/" + normalizedSkillHint,
387
+ ".cursor/skills/" + normalizedSkillHint,
388
+ ".copilot/skills/" + normalizedSkillHint,
389
+ ".opencode/skills/" + normalizedSkillHint,
390
+ ".gemini/skills/" + normalizedSkillHint,
391
+ ];
392
+ for (const relativeCandidate of candidateRelativePaths) {
393
+ const candidate = this.pathPort.join(repositoryDir, relativeCandidate);
394
+ const candidateSkillFile = this.pathPort.join(candidate, "SKILL.md");
395
+ if (await this.fileSystem.exists(candidateSkillFile)) {
396
+ return candidate;
397
+ }
398
+ }
399
+ }
400
+ const discovered = await this.discoverSkillDirectories(repositoryDir, 0, 7);
401
+ if (normalizedSkillHint) {
402
+ const matched = discovered.find((directoryPath) => {
403
+ const relativePath = path
404
+ .relative(repositoryDir, directoryPath)
405
+ .replace(/\\/g, "/");
406
+ const directoryName = path.basename(directoryPath).trim();
407
+ return (normalizeAgentId(directoryName) === normalizedSkillHint ||
408
+ normalizeAgentId(relativePath) === normalizedSkillHint);
409
+ });
410
+ if (matched) {
411
+ return matched;
412
+ }
413
+ }
414
+ if (discovered.length === 1) {
415
+ return discovered[0];
416
+ }
417
+ if (discovered.length === 0) {
418
+ throw new Error("No valid SKILL.md definitions were found in the source URL.");
419
+ }
420
+ const available = discovered
421
+ .slice(0, 10)
422
+ .map((directoryPath) => path.relative(repositoryDir, directoryPath))
423
+ .join(", ");
424
+ throw new Error(`Multiple skills were found in the source URL. Specify sourceSkillName. Available candidates: ${available}`);
425
+ }
426
+ async discoverSkillDirectories(rootDir, depth, maxDepth) {
427
+ if (depth > maxDepth) {
428
+ return [];
429
+ }
430
+ const skillFile = this.pathPort.join(rootDir, "SKILL.md");
431
+ const found = [];
432
+ if (await this.fileSystem.exists(skillFile)) {
433
+ found.push(rootDir);
434
+ }
435
+ const childDirectories = await this.fileSystem.listDirectories(rootDir);
436
+ for (const childDirectory of childDirectories) {
437
+ if (!childDirectory || childDirectory.startsWith(".git")) {
438
+ continue;
439
+ }
440
+ const childPath = this.pathPort.join(rootDir, childDirectory);
441
+ const nested = await this.discoverSkillDirectories(childPath, depth + 1, maxDepth);
442
+ found.push(...nested);
443
+ }
444
+ return dedupe(found).sort((left, right) => left.localeCompare(right));
445
+ }
446
+ async loadSkillsWithPrecedence(paths, agentId, config) {
447
+ const agentScopedDir = this.pathPort.join(paths.skillsDir, AGENT_SCOPED_STORE_DIR, agentId);
167
448
  const extraDirs = config.load.extraDirs.map(resolveUserPath);
168
449
  const sources = [
169
450
  {
@@ -171,6 +452,11 @@ export class SkillService {
171
452
  dir: paths.skillsDir,
172
453
  enabled: config.includeManaged,
173
454
  },
455
+ {
456
+ source: "managed",
457
+ dir: agentScopedDir,
458
+ enabled: config.includeManaged,
459
+ },
174
460
  ...extraDirs.map((dir) => ({
175
461
  source: "extra",
176
462
  dir,
@@ -275,6 +561,41 @@ export class SkillService {
275
561
  parsed.runtime = runtimeRecord;
276
562
  await this.fileSystem.writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`);
277
563
  }
564
+ async unassignSkillFromAgent(paths, agentId, skillId) {
565
+ const configPath = this.pathPort.join(paths.agentsDir, agentId, "config.json");
566
+ if (!(await this.fileSystem.exists(configPath))) {
567
+ return false;
568
+ }
569
+ const raw = await this.fileSystem.readFile(configPath);
570
+ const parsed = JSON.parse(raw);
571
+ const runtimeRecord = parsed.runtime &&
572
+ typeof parsed.runtime === "object" &&
573
+ !Array.isArray(parsed.runtime)
574
+ ? parsed.runtime
575
+ : {};
576
+ const skillsRecord = runtimeRecord.skills &&
577
+ typeof runtimeRecord.skills === "object" &&
578
+ !Array.isArray(runtimeRecord.skills)
579
+ ? runtimeRecord.skills
580
+ : {};
581
+ const assignedRaw = Array.isArray(skillsRecord.assigned)
582
+ ? skillsRecord.assigned
583
+ : [];
584
+ const assigned = [
585
+ ...new Set(assignedRaw
586
+ .map((value) => String(value).trim().toLowerCase())
587
+ .filter(Boolean)),
588
+ ];
589
+ const nextAssigned = assigned.filter((entry) => entry !== skillId);
590
+ if (nextAssigned.length === assigned.length) {
591
+ return false;
592
+ }
593
+ skillsRecord.assigned = nextAssigned;
594
+ runtimeRecord.skills = skillsRecord;
595
+ parsed.runtime = runtimeRecord;
596
+ await this.fileSystem.writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`);
597
+ return true;
598
+ }
278
599
  async reconcileRoleSkillsIfNeeded(paths, agentId, skillId) {
279
600
  const normalizedSkill = skillId.trim().toLowerCase();
280
601
  if (!ROLE_SKILL_IDS.has(normalizedSkill)) {
@@ -330,6 +651,7 @@ const BOARD_INDIVIDUAL_SKILL_ID = "og-board-individual";
330
651
  const BOARDS_SKILL_ID = "og-boards";
331
652
  const LEGACY_BOARD_MANAGER_SKILL_ID = "board-manager";
332
653
  const LEGACY_BOARD_INDIVIDUAL_SKILL_ID = "board-individual";
654
+ const AGENT_SCOPED_STORE_DIR = ".agent-scoped";
333
655
  const ROLE_SKILL_IDS = new Set([
334
656
  BOARDS_SKILL_ID,
335
657
  BOARD_MANAGER_SKILL_ID,
@@ -459,6 +781,84 @@ function resolveUserPath(value) {
459
781
  }
460
782
  return path.resolve(value);
461
783
  }
784
+ function normalizeWorkspaceSkillDirectories(input) {
785
+ if (!Array.isArray(input) || input.length === 0) {
786
+ return [];
787
+ }
788
+ const directories = [];
789
+ for (const candidate of input) {
790
+ const normalized = normalizeRelativeDirectory(candidate);
791
+ if (!normalized || directories.includes(normalized)) {
792
+ continue;
793
+ }
794
+ directories.push(normalized);
795
+ }
796
+ return directories;
797
+ }
798
+ function normalizeRelativeDirectory(value) {
799
+ if (!value) {
800
+ return null;
801
+ }
802
+ const normalized = value
803
+ .trim()
804
+ .replace(/\\/g, "/")
805
+ .replace(/^\/+/, "")
806
+ .replace(/\/+$/, "");
807
+ if (!normalized || normalized.startsWith("..")) {
808
+ return null;
809
+ }
810
+ const parts = normalized
811
+ .split("/")
812
+ .map((part) => part.trim())
813
+ .filter(Boolean);
814
+ if (parts.length === 0 || parts.includes("..")) {
815
+ return null;
816
+ }
817
+ return parts.join("/");
818
+ }
819
+ function stripGitHubTreePath(sourceUrl) {
820
+ try {
821
+ const parsed = new URL(sourceUrl);
822
+ if (parsed.hostname !== "github.com") {
823
+ return sourceUrl;
824
+ }
825
+ const segments = parsed.pathname.split("/").filter(Boolean);
826
+ if (segments.length >= 4 && segments[2] === "tree") {
827
+ const owner = segments[0];
828
+ const repo = segments[1]?.replace(/\.git$/i, "");
829
+ if (owner && repo) {
830
+ return `https://github.com/${owner}/${repo}.git`;
831
+ }
832
+ }
833
+ return sourceUrl;
834
+ }
835
+ catch {
836
+ return sourceUrl;
837
+ }
838
+ }
839
+ function resolveGitHubTreePathHint(sourceUrl) {
840
+ try {
841
+ const parsed = new URL(sourceUrl);
842
+ if (parsed.hostname !== "github.com") {
843
+ return undefined;
844
+ }
845
+ const segments = parsed.pathname.split("/").filter(Boolean);
846
+ if (segments.length <= 4 || segments[2] !== "tree") {
847
+ return undefined;
848
+ }
849
+ const relativeSegments = segments.slice(4);
850
+ if (relativeSegments.length === 0) {
851
+ return undefined;
852
+ }
853
+ return relativeSegments.join("/");
854
+ }
855
+ catch {
856
+ return undefined;
857
+ }
858
+ }
859
+ function dedupe(values) {
860
+ return [...new Set(values)];
861
+ }
462
862
  function escapeXml(value) {
463
863
  return value
464
864
  .replace(/&/g, "&amp;")