skillrepo 4.9.2 → 4.10.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.
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Merger for .gitignore — adds the paths `skillrepo init` writes that
3
- * must not be committed.
2
+ * Merger for .gitignore — the init-facing entry point over the CLI's
3
+ * single marked section (core writer: `../gitignore-section.mjs`).
4
4
  *
5
- * Always added:
5
+ * Always added by `skillrepo init`:
6
6
  * - `.env.local` — contains SKILLREPO_ACCESS_KEY, a live credential
7
7
  * - `.claude/settings.local.json` — Claude Code per-user settings
8
8
  *
@@ -10,16 +10,20 @@
10
10
  * - `.claude/skills/` — when any vendor maps to `claudeProject`
11
11
  * - `.agents/skills/` — when any vendor maps to `agentsProject`
12
12
  *
13
- * Idempotent: entries already present are skipped. A single call
14
- * either adds all missing entries in one grouped section or exits
15
- * without writing if they're all already present.
13
+ * The sync-time pre-flight (`file-write.mjs`) manages the managed-root
14
+ * entries through the same core, so every CLI-authored gitignore line
15
+ * lives in the one marked section the uninstall remover strips (#2361).
16
+ *
17
+ * Idempotent: entries already present are skipped; a legacy sync-time
18
+ * section is healed into the marked section as a side effect.
16
19
  */
17
20
 
18
- import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
19
- import { gitignorePath } from "../paths.mjs";
20
21
  import { placementTargetsFor } from "../file-write.mjs";
22
+ import {
23
+ ensureGitignoreEntries,
24
+ MANAGED_ROOT_ENTRIES,
25
+ } from "../gitignore-section.mjs";
21
26
 
22
- const SECTION_HEADER = "# SkillRepo CLI (added by `skillrepo init`)";
23
27
  const ALWAYS_ENTRIES = Object.freeze([
24
28
  ".env.local",
25
29
  ".claude/settings.local.json",
@@ -38,7 +42,7 @@ const ALWAYS_ENTRIES = Object.freeze([
38
42
  function entriesFor(options) {
39
43
  const entries = [...ALWAYS_ENTRIES];
40
44
  if (!Array.isArray(options.vendors) || options.vendors.length === 0) {
41
- entries.push(".claude/skills/");
45
+ entries.push(MANAGED_ROOT_ENTRIES.claudeProject);
42
46
  return entries;
43
47
  }
44
48
  let targets;
@@ -50,72 +54,25 @@ function entriesFor(options) {
50
54
  } catch {
51
55
  // Caller passed an unknown vendor; fall back to the always-set
52
56
  // entries plus `.claude/skills/` so we never under-protect.
53
- entries.push(".claude/skills/");
57
+ entries.push(MANAGED_ROOT_ENTRIES.claudeProject);
54
58
  return entries;
55
59
  }
56
- if (targets.includes("claudeProject")) entries.push(".claude/skills/");
57
- if (targets.includes("agentsProject")) entries.push(".agents/skills/");
60
+ for (const [target, entry] of Object.entries(MANAGED_ROOT_ENTRIES)) {
61
+ if (targets.includes(target)) entries.push(entry);
62
+ }
58
63
  return entries;
59
64
  }
60
65
 
61
66
  /**
62
67
  * Ensure the init-required paths are in .gitignore. Creates the file
63
68
  * if it doesn't exist. Idempotent — returns `"skipped"` if every
64
- * required entry is already present, `"created"` if the file didn't
65
- * exist, `"updated"` if it did and at least one entry was missing.
69
+ * required entry is already present and no legacy section needed
70
+ * healing, `"created"` if the file didn't exist, `"updated"` if it did
71
+ * and at least one entry was missing (or a legacy section was healed).
66
72
  *
67
73
  * @param {{ vendors?: string[], global?: boolean }} [options]
68
74
  * @returns {{ path: string; action: "created" | "updated" | "skipped"; added: string[] }}
69
75
  */
70
76
  export function mergeGitignore(options = {}) {
71
- const requiredEntries = entriesFor(options);
72
- const filePath = gitignorePath();
73
- const existing = readFileSafe(filePath);
74
-
75
- if (existing === null) {
76
- // Fresh .gitignore — write all required entries as one section.
77
- const content = renderSection(requiredEntries);
78
- writeFileSafe(filePath, content);
79
- return {
80
- path: ".gitignore",
81
- action: "created",
82
- added: [...requiredEntries],
83
- };
84
- }
85
-
86
- // Line-exact match check. `.env.local` as a line is distinct from
87
- // `.env.local.backup` or a comment `# .env.local` — split on
88
- // newlines and trim so we match the entry literally.
89
- const lines = existing.split(/\r?\n/).map((l) => l.trim());
90
- const missing = requiredEntries.filter((entry) => !lines.includes(entry));
91
-
92
- if (missing.length === 0) {
93
- return {
94
- path: ".gitignore",
95
- action: "skipped",
96
- added: [],
97
- };
98
- }
99
-
100
- // Append only the missing entries as a new section. Preserve the
101
- // original line-ending style.
102
- const lineEnding = existing.includes("\r\n") ? "\r\n" : "\n";
103
- const separator = existing.endsWith("\n") ? "" : lineEnding;
104
- const block = renderSection(missing, lineEnding);
105
- writeFileSafe(filePath, existing + separator + lineEnding + block);
106
-
107
- return {
108
- path: ".gitignore",
109
- action: "updated",
110
- added: missing,
111
- };
112
- }
113
-
114
- /**
115
- * Render the SkillRepo section with the given entries. The leading
116
- * blank line separates the section from whatever precedes it when
117
- * appending, and the trailing newline keeps the file POSIX-clean.
118
- */
119
- function renderSection(entries, lineEnding = "\n") {
120
- return SECTION_HEADER + lineEnding + entries.join(lineEnding) + lineEnding;
77
+ return ensureGitignoreEntries(entriesFor(options));
121
78
  }
package/src/lib/paths.mjs CHANGED
@@ -38,6 +38,17 @@ export const globalLastSyncPath = () => join(homedir(), ".claude", "skillrepo",
38
38
  export const globalNpmVersionCheckPath = () =>
39
39
  join(homedir(), ".claude", "skillrepo", ".npm-version-check");
40
40
 
41
+ /**
42
+ * Governance warn-on-new state (#2361 owner directive): which unmanaged
43
+ * dirs (per placement root) and gitignored declarations (per repo) the
44
+ * CLI has already warned about. Separate file from `.last-sync` —
45
+ * different concern (what the USER was told vs. library content state)
46
+ * and a schema bump in one must not gate the other. Same parent dir so
47
+ * uninstall's whole-directory removal covers it.
48
+ */
49
+ export const globalGovernanceSeenPath = () =>
50
+ join(homedir(), ".claude", "skillrepo", ".governance-seen");
51
+
41
52
  // ── Skill placement targets ────────────────────────────────────────────
42
53
  //
43
54
  // Per-vendor placement decisions live in `agent-registry.mjs`. This
@@ -22,7 +22,10 @@
22
22
 
23
23
  import { existsSync, readFileSync } from "node:fs";
24
24
  import { writeFileAtomic } from "../fs-utils.mjs";
25
- import { GITIGNORE_SECTION_HEADER } from "../artifact-registry.mjs";
25
+ import {
26
+ GITIGNORE_SECTION_HEADER,
27
+ LEGACY_GITIGNORE_SECTION_HEADER,
28
+ } from "../artifact-registry.mjs";
26
29
  import { gitignorePath } from "../paths.mjs";
27
30
 
28
31
  /**
@@ -50,10 +53,41 @@ export function removeGitignore({ dryRun = false } = {}) {
50
53
  // stays CRLF. `split(/\r?\n/)` discards the line-ending bytes, so
51
54
  // we detect them explicitly and rebuild on that basis.
52
55
  const lineEnding = original.includes("\r\n") ? "\r\n" : "\n";
53
- const lines = original.split(/\r?\n/);
56
+ let lines = original.split(/\r?\n/);
57
+
58
+ // Legacy sync-time section (#2361): pre-4.10.0 syncs wrote
59
+ // `.agents/skills/` under its own header, outside the marked
60
+ // section. If the merger's heal never ran (user uninstalls without
61
+ // syncing on 4.10.0+), strip the attributable shape here: the
62
+ // header line plus a directly-following `.agents/skills/` line.
63
+ // Any other line under the legacy header is user content and
64
+ // survives.
65
+ let legacyRemoved = 0;
66
+ const legacyIdx = lines.findIndex(
67
+ (l) => l === LEGACY_GITIGNORE_SECTION_HEADER,
68
+ );
69
+ if (legacyIdx !== -1) {
70
+ let legacyEnd = legacyIdx + 1;
71
+ if (legacyEnd < lines.length && lines[legacyEnd] === ".agents/skills/") {
72
+ legacyEnd += 1;
73
+ }
74
+ legacyRemoved = legacyEnd - legacyIdx;
75
+ lines = [...lines.slice(0, legacyIdx), ...lines.slice(legacyEnd)];
76
+ }
54
77
 
55
78
  const headerIdx = lines.findIndex((l) => l === GITIGNORE_SECTION_HEADER);
56
79
  if (headerIdx === -1) {
80
+ if (legacyRemoved > 0) {
81
+ if (dryRun) {
82
+ return {
83
+ path: ".gitignore",
84
+ action: "would-remove",
85
+ removed: legacyRemoved,
86
+ };
87
+ }
88
+ writeFileAtomic(filePath, lines.join(lineEnding));
89
+ return { path: ".gitignore", action: "removed", removed: legacyRemoved };
90
+ }
57
91
  return { path: ".gitignore", action: "skipped", removed: 0 };
58
92
  }
59
93
 
@@ -89,7 +123,8 @@ export function removeGitignore({ dryRun = false } = {}) {
89
123
  // ? 1 : 0)` gives the SkillRepo-line count.
90
124
  const consumedTrailingBlank =
91
125
  end > headerIdx + 1 && lines[end - 1] === "";
92
- const reportedRemoved = removedLines - (consumedTrailingBlank ? 1 : 0);
126
+ const reportedRemoved =
127
+ removedLines - (consumedTrailingBlank ? 1 : 0) + legacyRemoved;
93
128
 
94
129
  if (dryRun) {
95
130
  return {
package/src/lib/sync.mjs CHANGED
@@ -99,6 +99,18 @@
99
99
  * Consumers must distinguish these to
100
100
  * render accurate user messages (see
101
101
  * init.mjs step 7).
102
+ * @property {number} [unmanaged] - Unmanaged (non-CLI-managed) skill dirs
103
+ * detected in the managed placement roots
104
+ * this sync writes to (#2361). "unmanaged"
105
+ * is the one wire word for this concept —
106
+ * same as the receipts category and the
107
+ * warning copy; the design doc's internal
108
+ * term is "foreign content". Warn-only
109
+ * observability — NOT part of the change
110
+ * totals the hook printers gate on, so a
111
+ * 304 with unmanaged content stays silent
112
+ * in hook modes. Absent on a throttled
113
+ * skip (detection doesn't run there).
102
114
  * @property {string} syncedAt - ISO timestamp of the sync: the server
103
115
  * response `syncedAt` on a 200, or the
104
116
  * previously-cached sync timestamp on a
@@ -152,6 +164,16 @@ import {
152
164
  EXIT_VALIDATION,
153
165
  } from "./errors.mjs";
154
166
  import { computeSkillShas } from "./crypto-shas.mjs";
167
+ import {
168
+ scanForeignContent,
169
+ formatForeignWarnings,
170
+ checkDeclarationGitignored,
171
+ formatDeclarationIgnoredWarning,
172
+ readGovernanceSeen,
173
+ commitGovernanceSeen,
174
+ selectNewFindings,
175
+ normalizeStateKey,
176
+ } from "./foreign-content.mjs";
155
177
 
156
178
  /**
157
179
  * Current schema version of the .last-sync file. Bump on any
@@ -913,12 +935,26 @@ export async function runSync(options) {
913
935
  `Next sync will not be throttled.\n`,
914
936
  );
915
937
  }
938
+ // Foreign-content detection runs on the 304 path too (#2361): the
939
+ // library being unchanged says nothing about what else sits in the
940
+ // managed roots, and a deliberately-stable library would otherwise
941
+ // never surface violations. The on-disk managed set on a 304 is
942
+ // exactly the prior `.last-sync` skills map.
943
+ const unmanaged = emitGovernanceWarnings({
944
+ vendors,
945
+ global,
946
+ managedSkills: lastSync?.skills,
947
+ stderr,
948
+ hookMode: throttle === true,
949
+ });
950
+
916
951
  return {
917
952
  added: 0,
918
953
  updated: 0,
919
954
  removed: 0,
920
955
  notModified: true,
921
956
  fullSync,
957
+ unmanaged,
922
958
  syncedAt: lastSync?.syncedAt ?? new Date().toISOString(),
923
959
  };
924
960
  }
@@ -1130,6 +1166,17 @@ export async function runSync(options) {
1130
1166
  }
1131
1167
  }
1132
1168
 
1169
+ // Foreign-content detection (#2361): after writes and tombstones, so
1170
+ // skills delivered THIS round are already in `skillsMap` and never
1171
+ // misclassified as unmanaged. Warn-only; never affects the sync result.
1172
+ summary.unmanaged = emitGovernanceWarnings({
1173
+ vendors,
1174
+ global,
1175
+ managedSkills: skillsMap,
1176
+ stderr,
1177
+ hookMode: throttle === true,
1178
+ });
1179
+
1133
1180
  // Re-assert the full on-disk state via a sync receipt (#1832). This
1134
1181
  // confirmed-write signal — not the fetch itself — is what sets team
1135
1182
  // sync state. We post the COMPLETE post-sync on-disk set (`skillsMap`:
@@ -1157,6 +1204,76 @@ export async function runSync(options) {
1157
1204
 
1158
1205
  // ── Internals ──────────────────────────────────────────────────────────
1159
1206
 
1207
+ /**
1208
+ * Run the sync-time governance checks and write their warnings to the
1209
+ * caller's stderr (#2361): foreign-content detection over the managed
1210
+ * roots this sync writes to, plus the gitignored-declaration probe.
1211
+ * Returns the unmanaged-dir count for the summary.
1212
+ *
1213
+ * Warn-only by construction — never throws, never changes sync
1214
+ * behavior. Foreign dir NAMES appear only in these local warning
1215
+ * lines; anything that leaves the machine goes through
1216
+ * `receiptViolationSummary` (counts/category only — the H7 #2364
1217
+ * seam; see foreign-content.mjs for the privacy contract).
1218
+ *
1219
+ * WARN-ON-NEW (owner directive, 2026-08-07): a warning that repeats
1220
+ * unchanged findings on every sync trains users to ignore it. The
1221
+ * already-warned set persists in `.governance-seen`; only NEW findings
1222
+ * print, and a removed-then-reappearing dir re-warns. The returned
1223
+ * count is always the TOTAL currently on disk (state observability),
1224
+ * not just the new ones.
1225
+ *
1226
+ * HOOK SYNCS (`hookMode: true` — the --session-hook / --silent
1227
+ * branches) neither print warnings nor mark findings as seen: the
1228
+ * user isn't looking at those streams, and recording seen there would
1229
+ * permanently swallow the warning before an interactive sync ever
1230
+ * showed it. Governance warnings are an interactive-surface feature;
1231
+ * hook syncs still contribute the count. The throttled early-exit
1232
+ * returns before this runs at all (zero-work contract).
1233
+ *
1234
+ * @param {object} options
1235
+ * @param {string[]} [options.vendors]
1236
+ * @param {boolean} [options.global]
1237
+ * @param {Record<string, unknown> | null | undefined} options.managedSkills
1238
+ * @param {NodeJS.WritableStream} options.stderr
1239
+ * @param {boolean} [options.hookMode] - True for hook-triggered syncs.
1240
+ * @returns {number} Unmanaged-dir count across scanned roots (the
1241
+ * summary's `unmanaged` field — total, not just new).
1242
+ */
1243
+ function emitGovernanceWarnings({ vendors, global, managedSkills, stderr, hookMode }) {
1244
+ let unmanagedCount = 0;
1245
+ try {
1246
+ const scan = scanForeignContent({ vendors, global, managedSkills });
1247
+ unmanagedCount = scan.foreignCount;
1248
+ if (hookMode) return unmanagedCount;
1249
+
1250
+ const seen = readGovernanceSeen();
1251
+ const newScan = selectNewFindings(scan, seen);
1252
+ for (const line of formatForeignWarnings(newScan)) {
1253
+ stderr.write(`${line}\n`);
1254
+ }
1255
+
1256
+ // Gitignored-declaration warning, gated the same way: once per
1257
+ // repo while the state holds; leaving the gitignored state re-arms
1258
+ // it. Keyed by the NORMALIZED repo path (realpath + win32
1259
+ // case-fold) so two spellings of one checkout share a flag.
1260
+ const repoKey = normalizeStateKey(process.cwd());
1261
+ const decl = checkDeclarationGitignored();
1262
+ const declarationIgnored = decl.present && decl.ignored;
1263
+ if (declarationIgnored && !seen.declarations[repoKey]) {
1264
+ stderr.write(`${formatDeclarationIgnoredWarning()}\n`);
1265
+ }
1266
+
1267
+ // Narrow-merge commit: re-reads the file and rewrites ONLY this
1268
+ // sync's scanned roots + this repo's declaration flag, so a
1269
+ // concurrent sync in another repo keeps its update.
1270
+ commitGovernanceSeen({ scan, repoKey, declarationIgnored });
1271
+ } catch {
1272
+ // Detection is best-effort; a probe failure must never fail a sync.
1273
+ }
1274
+ return unmanagedCount;
1275
+ }
1276
+
1160
1277
  /**
1161
1278
  * Build sync-receipt entries from a `.last-sync` skills map (#1832).
1162
1279
  *
@@ -261,6 +261,25 @@ describe("runUpdate — --session-hook contract", () => {
261
261
  );
262
262
  });
263
263
 
264
+ it("stays silent on a zero-change sync even when foreign content is present (#2361)", async () => {
265
+ // Foreign dirs are observability, not changes: the hook-mode
266
+ // output gate keys on added+updated+removed+skipped, and the
267
+ // foreign count must never trip it. Both streams are black-holed
268
+ // in this mode, so the warning lines vanish too — session start
269
+ // stays clean.
270
+ const { mkdirSync, writeFileSync } = await import("node:fs");
271
+ const { join } = await import("node:path");
272
+ const dir = join(process.cwd(), ".claude", "skills", "hand-rolled");
273
+ mkdirSync(dir, { recursive: true });
274
+ writeFileSync(join(dir, "SKILL.md"), "---\nname: hand-rolled\n---\n");
275
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
276
+ await runUpdate(
277
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
278
+ { stdout },
279
+ );
280
+ assert.equal(stdout.text(), "", "foreign content must not break hook silence");
281
+ });
282
+
264
283
  it("is silent on 304 / up-to-date (no 'Syncing' output every session)", async () => {
265
284
  // INTENT: 304 means nothing changed. Printing "Syncing..." on
266
285
  // every session start with no value to show would clutter the
@@ -473,6 +492,38 @@ describe("runUpdate — --silent contract", () => {
473
492
  assert.equal(stdout.text(), "{}\n");
474
493
  });
475
494
 
495
+ it("emits no governance warning under --silent, and does not consume it (#2361)", async () => {
496
+ // Governance warnings are an interactive-surface feature: hook
497
+ // syncs print nothing (the user is not looking at vendor hook
498
+ // logs) and must NOT mark the finding as seen — the next
499
+ // interactive `update` is the first to warn. Stdout stays pure
500
+ // JSON either way (Gemini contract).
501
+ const { mkdirSync, writeFileSync } = await import("node:fs");
502
+ const { join } = await import("node:path");
503
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
504
+ const dir = join(process.cwd(), ".claude", "skills", "hand-rolled");
505
+ mkdirSync(dir, { recursive: true });
506
+ writeFileSync(join(dir, "SKILL.md"), "---\nname: hand-rolled\n---\n");
507
+ const stderr = createCaptureStream();
508
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
509
+ await runUpdate(
510
+ ["--key", VALID_KEY, "--url", serverUrl, "--silent"],
511
+ { stdout, stderr },
512
+ );
513
+ assert.equal(stdout.text(), "{}\n", "stdout must stay pure JSON");
514
+ assert.ok(!stderr.text().includes("not from your library"), "hook sync must not warn");
515
+
516
+ // Interactive update afterwards: the warning is still available.
517
+ const stdout2 = createCaptureStream();
518
+ const stderr2 = createCaptureStream();
519
+ await runUpdate(
520
+ ["--key", VALID_KEY, "--url", serverUrl],
521
+ { stdout: stdout2, stderr: stderr2 },
522
+ );
523
+ assert.match(stderr2.text(), /hand-rolled/);
524
+ assert.match(stderr2.text(), /skillrepo push/);
525
+ });
526
+
476
527
  it("emits `{}\\n` even when the sync produced changes (no progress lines on stdout)", async () => {
477
528
  server.setLibraryResponse({
478
529
  skills: [makeSkill("silent-test")],
@@ -122,6 +122,69 @@ describe("CLI E2E — read commands", () => {
122
122
  assert.match(r.stdout, /up to date/);
123
123
  });
124
124
 
125
+ it("update warns about a foreign skill dir on stderr and exits 0 (#2361)", async () => {
126
+ const { writeFileSync } = await import("node:fs");
127
+ // Hand-authored dir the CLI never wrote — a governance violation,
128
+ // surfaced but never touched.
129
+ mkdirSync(join(tempDir, ".claude", "skills", "hand-rolled"), { recursive: true });
130
+ writeFileSync(
131
+ join(tempDir, ".claude", "skills", "hand-rolled", "SKILL.md"),
132
+ "---\nname: hand-rolled\n---\n",
133
+ );
134
+ server.setLibraryResponse({
135
+ skills: [makeSkill("alice", "pdf-helper")],
136
+ removals: [],
137
+ syncedAt: "x",
138
+ });
139
+ const r = await runCli([
140
+ "update",
141
+ "--key", VALID_KEY,
142
+ "--url", `http://127.0.0.1:${port}`,
143
+ ]);
144
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
145
+ assert.match(r.stderr, /is not from your library/);
146
+ assert.match(r.stderr, /skillrepo push/);
147
+ assert.match(r.stderr, /\.claude\/skills\/hand-rolled\//);
148
+ // The foreign dir is surfaced, never destroyed.
149
+ assert.ok(existsSync(join(tempDir, ".claude", "skills", "hand-rolled", "SKILL.md")));
150
+ // The synced skill is managed — not warned about.
151
+ assert.ok(!r.stderr.includes("pdf-helper"));
152
+
153
+ // Warn-on-new: an identical second run repeats nothing.
154
+ const r2 = await runCli([
155
+ "update",
156
+ "--key", VALID_KEY,
157
+ "--url", `http://127.0.0.1:${port}`,
158
+ ]);
159
+ assert.equal(r2.status, 0, `stderr: ${r2.stderr}`);
160
+ assert.ok(
161
+ !r2.stderr.includes("hand-rolled"),
162
+ "unchanged finding must not re-warn on the next run",
163
+ );
164
+ });
165
+
166
+ it("update --json carries the unmanaged count (#2361)", async () => {
167
+ const { writeFileSync } = await import("node:fs");
168
+ mkdirSync(join(tempDir, ".claude", "skills", "rogue"), { recursive: true });
169
+ writeFileSync(
170
+ join(tempDir, ".claude", "skills", "rogue", "SKILL.md"),
171
+ "---\nname: rogue\n---\n",
172
+ );
173
+ server.setLibraryResponse({
174
+ skills: [makeSkill("alice", "pdf-helper")],
175
+ removals: [],
176
+ syncedAt: "x",
177
+ });
178
+ const r = await runCli([
179
+ "update", "--json",
180
+ "--key", VALID_KEY,
181
+ "--url", `http://127.0.0.1:${port}`,
182
+ ]);
183
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
184
+ const json = JSON.parse(r.stdout);
185
+ assert.equal(json.unmanaged, 1);
186
+ });
187
+
125
188
  it("update writes a single skill and prints the summary", async () => {
126
189
  server.setLibraryResponse({
127
190
  skills: [makeSkill("alice", "pdf-helper")],
@@ -956,7 +956,7 @@ describe("ensureFallbackGitignore — error surfacing", () => {
956
956
  });
957
957
 
958
958
  it("throws diskError when .gitignore cannot be READ (it is a directory → EISDIR)", () => {
959
- // The read-error branch in ensureAgentsGitignore: readFileSafe
959
+ // The read-error branch in the gitignore-section core: readFileSafe
960
960
  // re-throws any non-ENOENT error. Making `.gitignore` a DIRECTORY
961
961
  // causes readFileSync to throw EISDIR, which surfaces as a typed
962
962
  // diskError with a "Cannot read" message before any write is