skillrepo 4.11.1 → 4.13.0

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/README.md CHANGED
@@ -119,11 +119,23 @@ and just write the config + gitignore.
119
119
  "removed": 0,
120
120
  "notModified": false,
121
121
  "fullSync": true,
122
- "syncedAt": "2026-05-01T00:00:00.000Z"
122
+ "syncedAt": "2026-05-01T00:00:00.000Z",
123
+ "globalBoundary": {
124
+ "total": 0,
125
+ "counts": { "global_library": 0, "global_foreign": 0, "global_shadowed": 0 },
126
+ "names": [],
127
+ "skillsetDeclared": false
128
+ }
123
129
  }
124
130
  }
125
131
  ```
126
132
 
133
+ `sync.globalBoundary` (4.13.0) appears on every successful
134
+ project-scope sync and describes the personal-scope skills that also
135
+ load in sessions here; it is absent when the scan did not run
136
+ (`--global`, or a scan failure). `names` stays local to your machine —
137
+ sync reporting to your organization carries counts only.
138
+
127
139
  Field notes:
128
140
  - `vendors` is the resolved canonical-key list, NOT the raw `--agent` input. `--agent agents` produces every cohort vendor (cursor, windsurf, gemini, codex, cline, copilot); `--agent none` produces an empty array.
129
141
  - `sessionSync.cohortHooks[]` reports per-vendor outcomes for the auto-refresh hooks installed alongside the Claude Code SessionStart hook (one entry per cohort vendor with a non-null `agentHook` registry spec — Cursor, Gemini CLI, Codex CLI, VS Code + Copilot). `reason` is present only when `action: "failed"`. Empty array when `--no-session-sync` was passed or no cohort vendor was selected.
@@ -183,17 +195,46 @@ Sync also warns — once per repo, same rules — when a `skillrepo.json`
183
195
  at the repo root is gitignored: the skillset declaration only works as
184
196
  a committed file, so remove it from `.gitignore` and commit it.
185
197
 
198
+ Project syncs also disclose the personal scope (the global boundary):
199
+ skills under the global folders (`~/.claude/skills/`,
200
+ `~/.agents/skills/`, `~/.codeium/windsurf/skills/`) load in every
201
+ session run inside a project, whether or not they came through a
202
+ skillset. When any exist, a one-line disclosure says how many global
203
+ skills will also load — in a declared repo it also counts how many sit
204
+ outside the repo's skillset and how many collide with a skillset
205
+ member's name. Today the session auto-sync prints that line in Claude
206
+ Code only (the other agents' background sync hooks run silently by
207
+ their hook contracts — see `docs/vendor-paths.md` for each vendor's
208
+ channel); `skillrepo list` shows the same line everywhere. Interactive
209
+ syncs add a one-time warning per finding with the remediation, and
210
+ your organization's sync reporting carries counts only
211
+ (`global_library`, `global_foreign`, `global_shadowed`), never names.
212
+
186
213
  A repository can declare a skillset in a root `skillrepo.json`:
187
214
  `{"skillset": {"version": 1, "name": "<repo-identity>", "use":
188
215
  "owner/skillset-name"}}` (optional `extra: ["owner/skill"]`). The CLI
189
216
  finds the file from any subdirectory (it walks up to the repository
190
- root) and validates it before syncing. Skillset delivery is not yet
191
- available: a repository with a declaration fails its sync closed
192
- (exit code `6`) rather than receiving the whole library — a declared
193
- repo only ever receives its declared skillset. Keep the declaration
194
- while your team prepares for skillsets, or remove the `skillset`
195
- block to sync your whole library. A `skillrepo.json` without a
196
- `skillset` key is plain configuration and does not change sync.
217
+ root), validates it, and syncs that repository against the declared
218
+ skillset instead of your whole library — a declared repo only ever
219
+ receives its declared skillset. Skillset-scoped sync resolves the
220
+ complete set on every pass: skills added to the skillset appear,
221
+ skills removed from it are deleted from the repo's skill folders, and
222
+ everything lands at the repository root no matter which subdirectory
223
+ you run from. Deletes are guarded a directory the CLI did not write,
224
+ or one you modified locally, is never removed or overwritten; it stays
225
+ put with a warning and shows up in your team's sync reporting instead.
226
+ Declared entries in `extra` that cannot be honored (for example when
227
+ extras are not enabled for your organization) are skipped loudly with
228
+ the reason, never silently. If the registry is unreachable, the repo
229
+ keeps serving its last-verified skills and the sync still exits
230
+ successfully with a warning (the warning escalates once the state is
231
+ more than ~72 hours old). If the declaration itself cannot be honored
232
+ — unknown skillset, skillsets not enabled for your organization, an
233
+ invalid file — the sync fails closed with exit code `6` and nothing is
234
+ written or removed. A `skillrepo.json` without a `skillset` key is
235
+ plain configuration and does not change sync. See
236
+ [`docs/skillset-declaration.md`](docs/skillset-declaration.md) for the
237
+ full file-format reference.
197
238
 
198
239
  ### `get` — fetch a single skill
199
240
 
@@ -586,7 +627,7 @@ citations on each agent's read paths.
586
627
  | 3 | Disk error (cannot read or write a file/directory) |
587
628
  | 4 | Scope error (key lacks the required `registry:write` scope) |
588
629
  | 5 | Validation error (bad flag, malformed identifier, unknown vendor) |
589
- | 6 | Unresolvable skillset declaration — the repo's root `skillrepo.json` declares a skillset that cannot be honored (invalid or nested declaration, reserved `@` version syntax, or skillset delivery not yet available). Fail-closed: nothing was written or removed. |
630
+ | 6 | Unresolvable skillset declaration — the repo's root `skillrepo.json` declares a skillset that cannot be honored: an invalid or nested declaration, reserved `@` version syntax, an unknown skillset, or skillsets not enabled for your organization. Fail-closed: nothing was written or removed. A registry that is merely unreachable is NOT this class — the sync serves the last-verified skillset and exits `0` with a warning. |
590
631
 
591
632
  Pass `--verbose` to any command to print stack traces and retry
592
633
  attempts on failure.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.11.1",
3
+ "version": "4.13.0",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,6 +43,12 @@ import { detectAgents } from "../lib/detect-agents.mjs";
43
43
  import { walkDetectedPlacements } from "../lib/placement-walk.mjs";
44
44
  import { getAgentByKey } from "../lib/agent-registry.mjs";
45
45
  import { computeSkillState, rollupState, SKILL_STATE } from "../lib/drift.mjs";
46
+ import {
47
+ scanGlobalBoundary,
48
+ formatGlobalBoundaryDisclosure,
49
+ managedGlobalNamesFrom,
50
+ resolveBoundaryMemberContext,
51
+ } from "../lib/global-boundary.mjs";
46
52
 
47
53
  /**
48
54
  * Run `list`. Throws CliError on any failure.
@@ -56,7 +62,10 @@ export async function runList(argv, io = {}) {
56
62
  const stdout = io.stdout ?? process.stdout;
57
63
  const flags = resolveFlags(argv);
58
64
 
59
- // `list` is a read-only drift check — it must NEVER set sync state.
65
+ // `list` is a read-only drift check — it must NEVER set sync state,
66
+ // and (#2495) it never writes the governance-seen file either: the
67
+ // boundary disclosure below scans without committing warn-state, so
68
+ // running `list` can't consume a warning `update` owes the user.
60
69
  // Use the manifest read (#1832): metadata only, no file bodies, and the
61
70
  // server records no delivery for it. Per-skill drift is computed from
62
71
  // on-disk SHAs + `.last-sync` below, never from the response body, so
@@ -122,6 +131,42 @@ export async function runList(argv, io = {}) {
122
131
 
123
132
  printTable(augmented, detected, stdout);
124
133
  printFooter(augmented, libraryResponse.etag, lastSync, stdout, canUseGlyphs(stdout));
134
+ printGlobalBoundaryDisclosure(detectedKeys, lastSync, stdout);
135
+ }
136
+
137
+ /**
138
+ * Global-boundary disclosure for the table surface (#2495): one line,
139
+ * same format as the session-hook disclosure, printed only when the
140
+ * detected vendors' GLOBAL roots hold skill dirs. Read-only to the
141
+ * letter of list's contract above — no seen-state commit, no per-dir
142
+ * warning lines (those are `update`'s interactive job). Best-effort:
143
+ * the drift table must never fail because a disclosure probe did.
144
+ *
145
+ * The declaration is resolved TOLERANTLY, like the throttled sync
146
+ * exit: `list` is a reporting surface, so an invalid `skillrepo.json`
147
+ * stays `update`'s error to raise — here it just means the scan runs
148
+ * without a member set.
149
+ *
150
+ * @param {string[]} vendors - Detected vendor keys.
151
+ * @param {import("../lib/sync.mjs").SyncStateFile | null} lastSync
152
+ * @param {NodeJS.WritableStream} stdout
153
+ */
154
+ function printGlobalBoundaryDisclosure(vendors, lastSync, stdout) {
155
+ try {
156
+ const { memberNames, baseDir } = resolveBoundaryMemberContext();
157
+ const scan = scanGlobalBoundary({
158
+ vendors,
159
+ memberNames,
160
+ managedGlobalNames: managedGlobalNamesFrom(lastSync?.skills),
161
+ baseDir,
162
+ });
163
+ const disclosure = formatGlobalBoundaryDisclosure(scan);
164
+ // Two-space indent matches every other list line; the line itself
165
+ // is byte-identical to the session-hook disclosure.
166
+ if (disclosure) stdout.write(` ${disclosure}\n`);
167
+ } catch {
168
+ // Disclosure is best-effort on every surface.
169
+ }
125
170
  }
126
171
 
127
172
  // ── Per-skill augmentation ─────────────────────────────────────────────
@@ -102,6 +102,7 @@ const FILE_REMOVERS = Object.freeze({
102
102
  // Directory artifacts — handled inline.
103
103
  "skills-dir-project": null,
104
104
  "skills-dir-global": null,
105
+ "repo-sync-state-dir": null,
105
106
  "global-config-dir": null,
106
107
  });
107
108
 
@@ -141,12 +142,16 @@ function removeDirectoryArtifact(descriptor, { dryRun }) {
141
142
  error: `Cannot resolve ${path}: ${err.message}. Refusing to rmSync a path that doesn't resolve.`,
142
143
  };
143
144
  }
145
+ // "skills" = the placement caches, "skillrepo" = the global config
146
+ // dir, "repos" = the per-repo skillset state dir (#2365,
147
+ // repo-sync-state-dir descriptor).
148
+ const ALLOWED_DIR_BASENAMES = new Set(["skills", "skillrepo", "repos"]);
144
149
  const basename = realPath.split(/[\\/]/).filter(Boolean).pop();
145
- if (basename !== "skills" && basename !== "skillrepo") {
150
+ if (!ALLOWED_DIR_BASENAMES.has(basename)) {
146
151
  return {
147
152
  path: displayPath,
148
153
  action: "skipped",
149
- error: `Refusing to recursively remove ${path} (resolved to ${realPath}): basename "${basename}" is neither "skills" nor "skillrepo".`,
154
+ error: `Refusing to recursively remove ${path} (resolved to ${realPath}): basename "${basename}" is not a CLI-owned directory name (${[...ALLOWED_DIR_BASENAMES].join(", ")}).`,
150
155
  };
151
156
  }
152
157
 
@@ -46,6 +46,7 @@
46
46
  */
47
47
 
48
48
  import { runSync } from "../lib/sync.mjs";
49
+ import { formatGlobalBoundaryDisclosure } from "../lib/global-boundary.mjs";
49
50
  import {
50
51
  resolveFlags,
51
52
  effectiveVendors,
@@ -64,6 +65,14 @@ import {
64
65
  * - 304 Not Modified → exit 0, NO output.
65
66
  * - 200 with changes → exit 0, ONE line: `[SkillRepo] Library synced: N added, N updated, N removed.`
66
67
  * - Any failure → exit 0, ONE line: `[SkillRepo] Sync failed: <reason>.`
68
+ * - Global-boundary disclosure (#2495): when the sync's summary
69
+ * reports global skills that will also load in this session
70
+ * (`globalBoundary.total > 0`), ONE additional line prints on
71
+ * EVERY success path — after the sync line, or alone on the
72
+ * otherwise-silent 304/zero-delta/throttled paths. Plain stdout
73
+ * enters the session's model context, which is the point: the
74
+ * agent itself learns what extra skills are in play. Failure
75
+ * paths never disclose (the failure line stays the single line).
67
76
  *
68
77
  * The "exit 0 on all errors" contract is non-negotiable: a sync
69
78
  * failure must NEVER block a Claude Code session start. Users on a
@@ -162,10 +171,41 @@ export async function runUpdate(argv, io = {}) {
162
171
  const skipped = summary.skipped ?? 0;
163
172
  const total =
164
173
  summary.added + summary.updated + summary.removed + skipped;
174
+ // Global-boundary disclosure (#2495), computed BEFORE the silent
175
+ // branch below: the quiet 304/zero-delta/throttled session is
176
+ // the COMMON session, and it must still disclose — the line
177
+ // exists so the session's model context knows about the global
178
+ // skills loading alongside the synced set, not to report sync
179
+ // work. Success paths only; the catch below never reaches here.
180
+ // Formatting is its OWN failure domain (architect review r1): a
181
+ // formatter defect must degrade to "no line", never fall into
182
+ // the outer catch and report a successful sync as failed —
183
+ // "disclosure must never break a sync" applies to the printer
184
+ // exactly as it does to the scanner.
185
+ let disclosure = null;
186
+ try {
187
+ disclosure =
188
+ summary.globalBoundary && summary.globalBoundary.total > 0
189
+ ? formatGlobalBoundaryDisclosure(summary.globalBoundary)
190
+ : null;
191
+ } catch {
192
+ // Degrade to no disclosure line.
193
+ }
194
+ const writeDisclosureLine = () => {
195
+ if (!disclosure) return;
196
+ try {
197
+ stdout.write(`${disclosure}\n`);
198
+ } catch {
199
+ // Same failure domain as the formatter: a write failure on
200
+ // the cosmetic line must not become a "Sync failed" report.
201
+ }
202
+ };
165
203
  if (summary.notModified || total === 0) {
166
204
  // 304 Not Modified OR 200 with zero deltas — silent by
167
- // contract. Users should not see "Syncing..." on every
205
+ // contract (the boundary disclosure is the one sanctioned
206
+ // exception). Users should not see "Syncing..." on every
168
207
  // session for no visible value.
208
+ writeDisclosureLine();
169
209
  return;
170
210
  }
171
211
  stdout.write(
@@ -173,6 +213,7 @@ export async function runUpdate(argv, io = {}) {
173
213
  (skipped > 0 ? `, ${skipped} SKIPPED (could not be written)` : "") +
174
214
  `.\n`,
175
215
  );
216
+ writeDisclosureLine();
176
217
  } catch (err) {
177
218
  // The one-line failure message is the user's primary signal
178
219
  // that something's wrong. Do not surface a stack trace — the
@@ -39,6 +39,7 @@ import {
39
39
  gitignorePath,
40
40
  claudeSkillsProjectRoot,
41
41
  claudeSkillsGlobalRoot,
42
+ globalRepoStateDir,
42
43
  } from "./paths.mjs";
43
44
  import { join } from "node:path";
44
45
  import { homedir } from "node:os";
@@ -359,14 +360,28 @@ export const ARTIFACT_REGISTRY = Object.freeze([
359
360
  pathFn: claudeSkillsGlobalRoot,
360
361
  displayPath: "~/.claude/skills/",
361
362
  }),
363
+ Object.freeze({
364
+ // Per-repo skillset sync state (#2365, design doc D12): one JSON
365
+ // file per declared repo, keyed by realpath hash. Listed BEFORE
366
+ // its parent `global-config-dir` so the uninstall preview
367
+ // discloses the state class explicitly and the child is removed
368
+ // first (the parent sweep then finds it already gone — removal
369
+ // stays idempotent either way).
370
+ id: "repo-sync-state-dir",
371
+ scope: "global",
372
+ kind: "directory",
373
+ pathFn: globalRepoStateDir,
374
+ displayPath: "~/.claude/skillrepo/repos/",
375
+ }),
362
376
  Object.freeze({
363
377
  id: "global-config-dir",
364
378
  scope: "global",
365
379
  kind: "directory",
366
380
  // `~/.claude/skillrepo/` — the parent dir of config.json,
367
- // .last-sync, .npm-version-check, and .governance-seen (#2361).
368
- // Whole-directory removal is correct because every child is
369
- // CLI-owned and there's no room for user content.
381
+ // .last-sync, .npm-version-check, .governance-seen (#2361), and
382
+ // the repos/ state dir above (#2365). Whole-directory removal is
383
+ // correct because every child is CLI-owned and there's no room
384
+ // for user content.
370
385
  pathFn: () => join(homedir(), ".claude", "skillrepo"),
371
386
  displayPath: "~/.claude/skillrepo/",
372
387
  }),
@@ -56,6 +56,22 @@
56
56
 
57
57
  import { createHash } from "node:crypto";
58
58
 
59
+ /**
60
+ * Hex SHA-256 of a single UTF-8 string. The shared low-level digest
61
+ * this module is built on, exported (#2365) so the per-repo state
62
+ * store can hash its realpath-derived state key without hand-rolling
63
+ * a second `createHash` call site (shared-logic rule). Pure — no I/O.
64
+ *
65
+ * @param {string} value
66
+ * @returns {string}
67
+ */
68
+ export function sha256Hex(value) {
69
+ if (typeof value !== "string") {
70
+ throw new TypeError("sha256Hex: value must be a string");
71
+ }
72
+ return createHash("sha256").update(value, "utf8").digest("hex");
73
+ }
74
+
59
75
  /**
60
76
  * @typedef {Object} SkillFileLike
61
77
  * @property {string} path - Canonical POSIX path relative to skill root.
@@ -12,14 +12,15 @@
12
12
  * 6 — unresolvable skillset declaration (#2362, design doc D9): the
13
13
  * repo's `skillrepo.json` skillset declaration cannot be honored
14
14
  * (unparseable file, invalid sub-schema, nested declarations,
15
- * reserved `@` version syntax, or skillset delivery not yet
16
- * available). Fail-closed: nothing was written or removed. This
17
- * one code covers the WHOLE D9 fail-closed class, locally AND —
18
- * once H8 (#2365) wires the server the server's typed 422
19
- * `skillset_unresolvable` response (H6 #2363; renamed from the
20
- * never-emitted `harness_unresolvable` per the D19 extension,
21
- * owner decision 2026-08-11), so CI scripts get one stable
22
- * "declaration cannot be honored" signal.
15
+ * reserved `@` version syntax, or a server-determined cause
16
+ * unknown skillset, skillsets not enabled for the organization,
17
+ * declaration owner mismatch). Fail-closed: nothing was written
18
+ * or removed. This one code covers the WHOLE D9 fail-closed
19
+ * class, locally AND the server's typed 422
20
+ * `skillset_unresolvable` response consumed by the scoped sync
21
+ * (#2365; H6 #2363 emitter named per the D19 extension, never
22
+ * `harness_unresolvable`, owner decision 2026-08-11), so CI
23
+ * scripts get one stable "declaration cannot be honored" signal.
23
24
  *
24
25
  * These mirror the documented behavior in #683 and are the contract
25
26
  * shell users and CI scripts can rely on.
@@ -235,18 +235,24 @@ export function validateFilePath(rawPath) {
235
235
  * Resolve the absolute filesystem directory for a skill at a given
236
236
  * placement target. The directory does not need to exist yet.
237
237
  *
238
+ * `baseDir` (#2365) anchors PROJECT-scope targets at a skillset repo's
239
+ * detected root instead of cwd; global targets ignore it (they live in
240
+ * the user's home regardless of where the sync runs). Omitted →
241
+ * cwd-anchored, byte-identical to the pre-#2365 behavior (D2).
242
+ *
238
243
  * @param {PlacementTarget} target
239
244
  * @param {string} skillName
245
+ * @param {string} [baseDir]
240
246
  * @returns {string}
241
247
  */
242
- export function resolvePlacementDir(target, skillName) {
248
+ export function resolvePlacementDir(target, skillName, baseDir) {
243
249
  switch (target) {
244
250
  case "claudeProject":
245
- return claudeSkillsProject(skillName);
251
+ return claudeSkillsProject(skillName, baseDir);
246
252
  case "claudeGlobal":
247
253
  return claudeSkillsGlobal(skillName);
248
254
  case "agentsProject":
249
- return agentsSkillsProject(skillName);
255
+ return agentsSkillsProject(skillName, baseDir);
250
256
  case "agentsGlobal":
251
257
  return agentsSkillsGlobal(skillName);
252
258
  case "windsurfGlobal":
@@ -290,34 +296,25 @@ export function describePlacementTarget(target) {
290
296
  * detection in #1555) can enumerate skill placements without
291
297
  * duplicating the target→root mapping that already lives here.
292
298
  *
293
- * @param {PlacementTarget} target
294
- * @returns {string}
295
- */
296
- export function resolvePlacementRoot(target) {
297
- return placementRootFn(target)();
298
- }
299
-
300
- /**
301
- * Map a `PlacementTarget` to its parent root resolver. Used by
302
- * `cleanupOrphans` to know which directories to scan for `.tmp`/`.old`
303
- * siblings, and by `resolvePlacementRoot` above for read-side
304
- * consumers.
299
+ * `baseDir` anchors project-scope roots (#2365) — same contract as
300
+ * `resolvePlacementDir` above.
305
301
  *
306
302
  * @param {PlacementTarget} target
307
- * @returns {() => string}
303
+ * @param {string} [baseDir]
304
+ * @returns {string}
308
305
  */
309
- function placementRootFn(target) {
306
+ export function resolvePlacementRoot(target, baseDir) {
310
307
  switch (target) {
311
308
  case "claudeProject":
312
- return claudeSkillsProjectRoot;
309
+ return claudeSkillsProjectRoot(baseDir);
313
310
  case "claudeGlobal":
314
- return claudeSkillsGlobalRoot;
311
+ return claudeSkillsGlobalRoot();
315
312
  case "agentsProject":
316
- return agentsSkillsProjectRoot;
313
+ return agentsSkillsProjectRoot(baseDir);
317
314
  case "agentsGlobal":
318
- return agentsSkillsGlobalRoot;
315
+ return agentsSkillsGlobalRoot();
319
316
  case "windsurfGlobal":
320
- return windsurfSkillsGlobalRoot;
317
+ return windsurfSkillsGlobalRoot();
321
318
  default: {
322
319
  throw validationError(`Unknown placement target: ${target}`);
323
320
  }
@@ -412,6 +409,9 @@ export function readFrontmatterName(files) {
412
409
  * @param {object} options
413
410
  * @param {string[]} [options.vendors] - Vendor keys; required unless `global`.
414
411
  * @param {boolean} [options.global] - If true, write to global Claude Code dir.
412
+ * @param {string} [options.baseDir] - Project-scope anchor (#2365): a
413
+ * skillset-scoped sync writes at the repo's detected root, not
414
+ * cwd. Applies to placement dirs AND the .gitignore heal.
415
415
  * @returns {{ written: string[] }} Per-target absolute directory paths.
416
416
  *
417
417
  * Note: `writeSkillDir` does NOT short-circuit when an existing skill on
@@ -444,12 +444,12 @@ export function writeSkillDir(skill, options = {}) {
444
444
  // failure here AFTER a successful write to an earlier target would
445
445
  // leave the user with a half-applied state and an error message
446
446
  // that doesn't reflect what's actually on disk.
447
- ensureProjectGitignore(targets);
447
+ ensureProjectGitignore(targets, options.baseDir);
448
448
 
449
449
  const written = [];
450
450
 
451
451
  for (const target of targets) {
452
- const targetDir = resolvePlacementDir(target, skill.name);
452
+ const targetDir = resolvePlacementDir(target, skill.name, options.baseDir);
453
453
 
454
454
  try {
455
455
  writeSkillToDir(skill, targetDir);
@@ -473,6 +473,7 @@ export function writeSkillDir(skill, options = {}) {
473
473
  * @param {object} options
474
474
  * @param {string[]} [options.vendors]
475
475
  * @param {boolean} [options.global]
476
+ * @param {string} [options.baseDir] - Project-scope anchor (#2365).
476
477
  * @returns {{ removed: string[], notFound: string[] }}
477
478
  */
478
479
  export function removeSkillDir(skillName, options = {}) {
@@ -485,7 +486,7 @@ export function removeSkillDir(skillName, options = {}) {
485
486
  const notFound = [];
486
487
 
487
488
  for (const target of targets) {
488
- const targetDir = resolvePlacementDir(target, skillName);
489
+ const targetDir = resolvePlacementDir(target, skillName, options.baseDir);
489
490
  if (!existsSync(targetDir)) {
490
491
  notFound.push(targetDir);
491
492
  continue;
@@ -533,6 +534,7 @@ export function removeSkillDir(skillName, options = {}) {
533
534
  * @param {object} options
534
535
  * @param {string[]} [options.vendors]
535
536
  * @param {boolean} [options.global]
537
+ * @param {string} [options.baseDir] - Project-scope anchor (#2365).
536
538
  * @returns {{ cleaned: string[] }}
537
539
  */
538
540
  export function cleanupOrphans(options = {}) {
@@ -543,19 +545,19 @@ export function cleanupOrphans(options = {}) {
543
545
  global: !!options.global,
544
546
  });
545
547
  for (const target of targets) {
546
- roots.add(placementRootFn(target)());
548
+ roots.add(resolvePlacementRoot(target, options.baseDir));
547
549
  }
548
550
  } else if (options.global) {
549
551
  // No vendors under --global — sweep every known global root so a
550
552
  // stale orphan from any prior --global write is cleaned.
551
553
  for (const target of GLOBAL_TARGETS) {
552
- roots.add(placementRootFn(target)());
554
+ roots.add(resolvePlacementRoot(target, options.baseDir));
553
555
  }
554
556
  } else {
555
557
  // No vendors and no --global — sweep every root we know about so
556
558
  // any orphan from any prior run is cleaned.
557
559
  for (const target of ALL_TARGETS) {
558
- roots.add(placementRootFn(target)());
560
+ roots.add(resolvePlacementRoot(target, options.baseDir));
559
561
  }
560
562
  }
561
563
 
@@ -836,13 +838,15 @@ function writeSkillToDir(skill, targetDir) {
836
838
  * suspenders wrap here only converts a non-CliError escape.
837
839
  *
838
840
  * @param {PlacementTarget[]} targets
841
+ * @param {string} [baseDir] - Project-scope anchor (#2365): the heal
842
+ * targets the repo root's .gitignore on a skillset-scoped sync.
839
843
  */
840
- function ensureProjectGitignore(targets) {
844
+ function ensureProjectGitignore(targets, baseDir) {
841
845
  try {
842
- ensureManagedRootIgnores(targets);
846
+ ensureManagedRootIgnores(targets, { baseDir });
843
847
  } catch (err) {
844
848
  if (err instanceof CliError) throw err;
845
- throw diskError(`Cannot update ${gitignorePath()}: ${err.message}`, {
849
+ throw diskError(`Cannot update ${gitignorePath(baseDir)}: ${err.message}`, {
846
850
  cause: err,
847
851
  });
848
852
  }