@ngockhoale/ukit 2.3.6 → 2.3.8

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/CHANGELOG.md CHANGED
@@ -2,6 +2,88 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.3.8 - 2026-09-10
6
+
7
+ Freeze-sweep wave 4: every fix in this release targets the "agent silently stops working"
8
+ class — lost permission rules that turn into human-waiting prompts, a session start that
9
+ wipes a sibling session's near-cap warnings, torn state files, and lost concurrency
10
+ bookkeeping. All found by verified reproducers and fixed with regression coverage.
11
+
12
+ **P1 — SessionStart wiped a sibling session's compact-pressure state.** The SessionStart
13
+ reset hook deleted the whole project-wide `compact-pressure.json`, so starting a second
14
+ session in the same project zeroed the first session's pressure history: its near-cap
15
+ warnings stopped firing, it ran into the real context cap, and it stalled. Pressure state
16
+ is now a session-keyed document (`{"v":2,"sessions":{<session_id>:{…}}}`) with the newest
17
+ record's flat fields still projected at the top level for raw readers. Every writer and
18
+ reader threads the hook payload's `session_id` (compact-threshold CLI, output-compression,
19
+ reinject-context PreCompact, context-hardcap-gate); a brand-new session id starts clean
20
+ and never inherits a sibling's totals; the reset hook removes only the calling session's
21
+ record; sessions are pruned to the 8 most recent; a legacy flat file upgrades in place.
22
+ Ships in both `src/core/compact/threshold.js` and the runtime mirror.
23
+
24
+ **P1 — concurrent auto-allow invocations lost Bash permission rules.** The hook's
25
+ read-modify-write of `settings.local.json` and `permission-usage.json` was unlocked, and
26
+ 30 concurrent invocations kept only 26/30 rules and 23/30 usage records — a lost rule
27
+ means the next run of that command prompts for permission again, i.e. the agent sits
28
+ waiting for a human click. Both files are now written inside one mkdir lock on
29
+ `<permission-usage.json>.lock` (protocol-compatible with `withFileLock`, fail-open ≤5s,
30
+ parent directory created before locking) with atomic tmp+rename writes, so Claude Code
31
+ can never observe a torn settings file either — a torn parse would drop the entire allow
32
+ list and prompt on every command. `auto-prune-bash.sh` now shares the same lock and
33
+ atomic writes.
34
+
35
+ **P2 — index cache writes lost entries under concurrency.** `cache-utils.mjs`
36
+ read-modify-write cycles were unlocked: 20 concurrent writers kept 2 entries. All cache
37
+ reads-modify-writes (write + touch paths) now run under `withFileLock` with atomic
38
+ compact writes (cache files stay single-line JSON by contract).
39
+
40
+ **P2 — verification-progress and execution-ledger counters lost concurrent updates.**
41
+ `verification-guard.sh` now records attempts through a locked re-read + merge (sync
42
+ `Atomics.wait` backoff so the hook's `process.exit` flow is untouched) and writes
43
+ atomically; `execution-ledger.mjs`'s continuation/notified counters re-read inside
44
+ `withFileLock` so parallel subagent Stop hooks cannot reset each other's budgets.
45
+
46
+ Full suite: 81 files, 1,373 tests green, including two shuffled runs.
47
+
48
+ ## 2.3.7 - 2026-09-10
49
+
50
+ Bug-sweep wave 3: a data-loss escape in uninstall, cross-process races on shared runtime
51
+ state, one flaky test, and one vulnerable runtime dependency — all found by verified
52
+ reproducers and fixed with regression coverage.
53
+
54
+ **P0 — uninstall could delete files outside the project root.** Tracked-path ownership
55
+ checks were purely lexical: with `.claude` (or any parent directory) symlinked to an
56
+ external location, uninstall's recursive remove resolved through the symlink and deleted
57
+ whatever it pointed at — e.g. a shared dotfiles directory. Uninstall now inspects every
58
+ directory component between the project root and each tracked entry (the entry itself may
59
+ still be a tracked symlink, which is unlinked, never followed); entries with a symlinked
60
+ parent are refused, warned, and reported in a new `skippedSymlinkParents` field in both
61
+ dry-run and real results.
62
+
63
+ **P1 — concurrent state mutations silently lost updates.** `appendOutputHistory` and the
64
+ compact-pressure writers (prompt, output, plan, and the plain state write) performed
65
+ unsynchronized read-modify-write cycles on files under `.ukit/storage/cache/`. With
66
+ parallel subagents firing hooks in separate processes, 30 concurrent updates left a single
67
+ surviving output-history entry (of a 25 cap) and 17 of 510 accumulated sessionTokens —
68
+ understating context pressure and delaying compaction. All mutations now run under
69
+ `withFileLock`, a directory-lock protocol beside each state file (atomic `mkdir`, stale
70
+ reclaim after 10s, fail-open after 5s so a stuck lock can never freeze a hook). The lock
71
+ ships in both `src/core/fileOps.js` and the runtime mirror `token-utils.mjs` using the
72
+ same `<file>.lock` path, so CLI processes and hook processes serialize against each
73
+ other; mirror state writes also became atomic (tmp + rename). An interop test runs both
74
+ implementations against one project root to keep the protocols locked together.
75
+
76
+ **Flaky test** — the `--no-outline` index-tools test queued a `getFileOutline` once-mock
77
+ its own flag guaranteed would never be consumed; `vi.clearAllMocks()` drains no once-queues,
78
+ so test order leaked the stale value into the next test (18/30 shuffled solo runs failed).
79
+ The dead queue is gone; the full suite is now 3/3 green under `--sequence.shuffle`.
80
+
81
+ **Dependency** — runtime dependency `yaml` 2.8.2 → 2.9.0 (GHSA-48c2-rrv3-qjmp, stack
82
+ overflow via deeply nested YAML — UKit parses project-supplied manifests). Production
83
+ dependency audit is now 0 findings.
84
+
85
+ Full suite: 78 files, 1,359 tests green.
86
+
5
87
  ## 2.3.6 - 2026-09-10
6
88
 
7
89
  Anti-stall coverage parity release: the compact-recovery and stop-reason directives that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.3.6",
3
+ "version": "2.3.8",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -65,7 +65,7 @@
65
65
  "node": ">=20"
66
66
  },
67
67
  "dependencies": {
68
- "yaml": "^2.8.2"
68
+ "yaml": "^2.9.0"
69
69
  },
70
70
  "devDependencies": {
71
71
  "vitest": "^2"
@@ -1,4 +1,4 @@
1
- import { readJsonIfExists, writeJson } from '../fileOps.js';
1
+ import { readJsonIfExists, writeJson, withFileLock } from '../fileOps.js';
2
2
  import { buildRuntimePaths } from '../runtimePaths.js';
3
3
  import { buildCompactMachineKey, compressLine, estimateTokenCount } from '../token/index.js';
4
4
  import { compactContextBlock } from './index.js';
@@ -553,7 +553,72 @@ export function resolveThresholdCompactBudget({
553
553
  };
554
554
  }
555
555
 
556
+ // ---- session-scoped pressure document ---------------------------------------
557
+ // compact-pressure.json holds ONE record per session id. A session starting must never
558
+ // zero another live session's pressure bookkeeping (its hard-cap gate and compact
559
+ // advisories), and a brand-new session must not inherit a sibling's totals. The newest
560
+ // record's flat fields stay projected at the document's top level so readers that predate
561
+ // this shape (raw JSON readers) still see a coherent state.
562
+ // Mirrors templates/.claude/ukit/runtime/compact-threshold.mjs — keep in lockstep.
563
+ const PRESSURE_SESSIONS_MAX = 8;
564
+
565
+ function normalizeSessionId(value) {
566
+ const id = typeof value === 'string' ? value.trim() : '';
567
+ return id || null;
568
+ }
569
+
570
+ function readPressureDocument(raw) {
571
+ if (raw && typeof raw === 'object' && raw.sessions && typeof raw.sessions === 'object') {
572
+ const sessions = {};
573
+ for (const [id, record] of Object.entries(raw.sessions)) {
574
+ if (id && record && typeof record === 'object') {
575
+ sessions[id] = record;
576
+ }
577
+ }
578
+ return sessions;
579
+ }
580
+ // Legacy single-record file: preserve the in-flight session under a shared bucket.
581
+ return raw && typeof raw === 'object' ? { default: raw } : {};
582
+ }
583
+
584
+ function pickPressureSession(sessions, sessionId) {
585
+ if (sessionId) {
586
+ // A session id that has no record yet starts clean — it must not inherit another
587
+ // session's totals, and the other way round nothing here touches that session.
588
+ return { id: sessionId, record: sessions[sessionId] ?? null };
589
+ }
590
+ const entries = Object.entries(sessions);
591
+ if (!entries.length) {
592
+ return { id: 'default', record: null };
593
+ }
594
+ const newest = entries
595
+ .sort(([, left], [, right]) => finiteNumber(right?.updatedAt, 0) - finiteNumber(left?.updatedAt, 0))[0];
596
+ return { id: newest[0], record: newest[1] };
597
+ }
598
+
599
+ function resolvePressureRecord(raw, config) {
600
+ if (!raw || typeof raw !== 'object' || !raw.sessions || typeof raw.sessions !== 'object') {
601
+ return raw; // flat legacy state — normalized as-is below
602
+ }
603
+ const { record } = pickPressureSession(readPressureDocument(raw), normalizeSessionId(config?.sessionId));
604
+ return record;
605
+ }
606
+
607
+ function projectPressureDocument(sessions) {
608
+ const newestFirst = Object.entries(sessions)
609
+ .sort(([, left], [, right]) => finiteNumber(right?.updatedAt, 0) - finiteNumber(left?.updatedAt, 0))
610
+ .slice(0, PRESSURE_SESSIONS_MAX);
611
+ const newestRecord = newestFirst[0]?.[1] ?? null;
612
+ return {
613
+ ...(newestRecord && typeof newestRecord === 'object' ? newestRecord : {}),
614
+ updatedAt: finiteNumber(newestRecord?.updatedAt, Date.now()),
615
+ v: 2,
616
+ sessions: Object.fromEntries(newestFirst),
617
+ };
618
+ }
619
+
556
620
  export function buildCompactPressureState(rawState = null, config = {}) {
621
+ rawState = resolvePressureRecord(rawState, config);
557
622
  const thresholds = buildCompactThresholds(config);
558
623
  const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
559
624
  const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
@@ -937,27 +1002,62 @@ export async function readCompactPressureState(projectRoot, config = {}) {
937
1002
  return buildCompactPressureState(await readJsonIfExists(runtimePaths.compactPressurePath), config);
938
1003
  }
939
1004
 
1005
+ // All compact-pressure mutations share one lock on the state file: without it,
1006
+ // concurrent hook processes (parallel subagents) and same-process flows interleave
1007
+ // their read-modify-write cycles and silently drop each other's sessionTokens.
1008
+ // Mutations are session-scoped: only the calling session's record is rewritten;
1009
+ // sibling session records pass through untouched.
1010
+ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
1011
+ const runtimePaths = buildRuntimePaths(projectRoot);
1012
+ return withFileLock(runtimePaths.compactPressurePath, async () => {
1013
+ const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
1014
+ const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1015
+ const current = buildCompactPressureState(record, config);
1016
+ const next = mutator(current);
1017
+ const normalized = buildCompactPressureState(next, config);
1018
+ sessions[id] = { ...normalized, updatedAt: Date.now() };
1019
+ await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
1020
+ return normalized;
1021
+ });
1022
+ }
1023
+
940
1024
  export async function writeCompactPressureState(projectRoot, state, config = {}) {
941
1025
  const runtimePaths = buildRuntimePaths(projectRoot);
942
- const normalized = buildCompactPressureState(state, config);
943
- await writeJson(runtimePaths.compactPressurePath, normalized);
944
- return normalized;
1026
+ return withFileLock(runtimePaths.compactPressurePath, async () => {
1027
+ const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
1028
+ const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1029
+ const normalized = buildCompactPressureState(state, config);
1030
+ sessions[id] = { ...normalized, updatedAt: Date.now() };
1031
+ await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
1032
+ return normalized;
1033
+ });
1034
+ }
1035
+
1036
+ function pressureSessionConfig(payload = {}, config = {}) {
1037
+ const sessionId = normalizeSessionId(payload?.sessionId) ?? normalizeSessionId(config?.sessionId);
1038
+ return sessionId ? { ...config, sessionId } : config;
945
1039
  }
946
1040
 
947
1041
  export async function updateCompactPressureFromPrompt(projectRoot, payload, config = {}) {
948
- const current = await readCompactPressureState(projectRoot, config);
949
- const next = registerPromptPressure(current, payload, config);
950
- return writeCompactPressureState(projectRoot, next, config);
1042
+ return mutateCompactPressureState(
1043
+ projectRoot,
1044
+ (current) => registerPromptPressure(current, payload, config),
1045
+ pressureSessionConfig(payload, config),
1046
+ );
951
1047
  }
952
1048
 
953
1049
  export async function updateCompactPressureFromOutput(projectRoot, payload, config = {}) {
954
- const current = await readCompactPressureState(projectRoot, config);
955
- const next = registerOutputPressure(current, payload, config);
956
- return writeCompactPressureState(projectRoot, next, config);
1050
+ return mutateCompactPressureState(
1051
+ projectRoot,
1052
+ (current) => registerOutputPressure(current, payload, config),
1053
+ pressureSessionConfig(payload, config),
1054
+ );
957
1055
  }
958
1056
 
959
1057
  export async function writeThresholdCompactPlan(projectRoot, plan, config = {}) {
960
- const current = await readCompactPressureState(projectRoot, config);
961
- const next = registerThresholdCompactPlan(current, plan, config);
962
- return writeCompactPressureState(projectRoot, next, config);
1058
+ return mutateCompactPressureState(
1059
+ projectRoot,
1060
+ (current) => registerThresholdCompactPlan(current, plan, config),
1061
+ pressureSessionConfig(plan, config),
1062
+ );
963
1063
  }
@@ -131,6 +131,75 @@ export async function writeJson(filePath, data) {
131
131
  await writeFileAtomic(filePath, `${JSON.stringify(data, null, 2)}\n`);
132
132
  }
133
133
 
134
+ const LOCK_STALE_MS = 10_000;
135
+ const LOCK_MAX_WAIT_MS = 5_000;
136
+
137
+ function lockBackoffDelayMs() {
138
+ return 3 + Math.floor(Math.random() * 9);
139
+ }
140
+
141
+ function sleep(ms) {
142
+ return new Promise((resolve) => setTimeout(resolve, ms));
143
+ }
144
+
145
+ /**
146
+ * Serialize read-modify-write mutations of a shared state file — across processes
147
+ * (hook invocations run as separate node processes) and across concurrent async
148
+ * flows in one process (parallel subagents). The lock is a directory created next
149
+ * to the target file: `mkdir` is atomic, so exactly one caller can create it.
150
+ * A crashed holder is reclaimed once the directory's mtime exceeds staleMs.
151
+ * Liveness wins over strictness: if the lock cannot be acquired within maxWaitMs
152
+ * the callback runs anyway (the pre-lock behaviour) — these state files are
153
+ * advisory caches, and losing an update beats freezing a hook mid-flight.
154
+ * Holders must keep their critical section far below staleMs; nothing refreshes
155
+ * the lock mtime, so a section that somehow runs longer can have its lock stolen.
156
+ * @param {string} filePath - state file the mutation targets (lock lives beside it)
157
+ * @param {() => Promise<*>} fn - critical section; its result is returned
158
+ * @returns {Promise<*>} whatever fn resolves with
159
+ */
160
+ export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
161
+ const lockPath = `${filePath}.lock`;
162
+ const startedAt = Date.now();
163
+ let locked = false;
164
+
165
+ while (!locked) {
166
+ try {
167
+ await ensureDir(path.dirname(lockPath));
168
+ await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
169
+ locked = true;
170
+ break;
171
+ } catch (error) {
172
+ if (error?.code !== 'EEXIST') throw error;
173
+ }
174
+
175
+ // Someone holds the lock. Reclaim it when it looks abandoned; otherwise back off.
176
+ try {
177
+ const stat = await fs.stat(lockPath);
178
+ if (Date.now() - stat.mtimeMs > staleMs) {
179
+ await fs.rm(lockPath, { recursive: true, force: true });
180
+ continue; // the slot is free now — retry immediately
181
+ }
182
+ } catch {
183
+ continue; // lock vanished between mkdir and stat — retry immediately
184
+ }
185
+
186
+ if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
187
+ await sleep(lockBackoffDelayMs());
188
+ }
189
+
190
+ try {
191
+ return await fn();
192
+ } finally {
193
+ if (locked) {
194
+ try {
195
+ await fs.rm(lockPath, { recursive: true, force: true });
196
+ } catch {
197
+ // best-effort release; a stale lock is reclaimed by the next waiter
198
+ }
199
+ }
200
+ }
201
+ }
202
+
134
203
  /**
135
204
  * Create a directory symlink (macOS/Linux) or junction (Windows).
136
205
  * Junctions on Windows don't require elevated privileges.
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { readJsonIfExists, writeJson } from '../fileOps.js';
3
+ import { readJsonIfExists, writeJson, withFileLock } from '../fileOps.js';
4
4
  import { recordCompaction } from '../compact/index.js';
5
5
  import { buildRuntimePaths } from '../runtimePaths.js';
6
6
  import {
@@ -1075,16 +1075,21 @@ export async function appendOutputHistory(projectRoot, entry, options = {}) {
1075
1075
  }
1076
1076
 
1077
1077
  const runtimePaths = buildRuntimePaths(projectRoot);
1078
- const history = await readOutputHistory(projectRoot, options);
1079
- const normalizedKey = buildOutputHistoryDedupeKey(normalizedEntry);
1080
- const dedupedExistingEntries = history.entries.filter((candidate) => !(
1081
- buildOutputHistoryDedupeKey(candidate) === normalizedKey
1082
- ));
1083
- const nextDocument = normalizeOutputHistoryDocument({
1084
- entries: [normalizedEntry, ...dedupedExistingEntries],
1085
- }, options);
1086
- await writeJson(runtimePaths.outputHistoryPath, nextDocument);
1087
- return nextDocument;
1078
+ // The read-merge-write cycle runs under the state-file lock: concurrent tool-output
1079
+ // captures (parallel subagents fire hooks in separate processes) otherwise read the
1080
+ // same old history and overwrite each other, leaving only one survivor entry.
1081
+ return withFileLock(runtimePaths.outputHistoryPath, async () => {
1082
+ const history = await readOutputHistory(projectRoot, options);
1083
+ const normalizedKey = buildOutputHistoryDedupeKey(normalizedEntry);
1084
+ const dedupedExistingEntries = history.entries.filter((candidate) => !(
1085
+ buildOutputHistoryDedupeKey(candidate) === normalizedKey
1086
+ ));
1087
+ const nextDocument = normalizeOutputHistoryDocument({
1088
+ entries: [normalizedEntry, ...dedupedExistingEntries],
1089
+ }, options);
1090
+ await writeJson(runtimePaths.outputHistoryPath, nextDocument);
1091
+ return nextDocument;
1092
+ });
1088
1093
  }
1089
1094
 
1090
1095
  export function summarizeOutputHistory(rawHistory) {
@@ -64,6 +64,37 @@ function isSameOrDescendantProjectPath(candidatePath, parentPath) {
64
64
  return candidatePath === parentPath || candidatePath.startsWith(`${parentPath}/`);
65
65
  }
66
66
 
67
+ // A tracked path is only safe to delete when every directory between the project root
68
+ // and the entry itself is a real directory. The prefix allowlist above is purely
69
+ // lexical: if `.claude` (or any parent) is a symlink to somewhere outside the project,
70
+ // `fs.rm(recursive)` resolves THROUGH it and would delete whatever it points at —
71
+ // e.g. a shared dotfiles directory linked in as .claude. The entry itself may be a
72
+ // symlink (tracked links are unlinked, never followed); only its parents matter.
73
+ async function hasSymlinkedParent(projectRoot, absolutePath) {
74
+ const root = path.resolve(projectRoot);
75
+ const relative = path.relative(root, path.resolve(absolutePath));
76
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
77
+ // Outside the project root entirely — nothing here is safe to delete.
78
+ return true;
79
+ }
80
+ const segments = relative.split(path.sep);
81
+ segments.pop(); // the entry itself is allowed to be a symlink (removeLinkOnly unlinks it)
82
+ let current = root;
83
+ for (const segment of segments) {
84
+ current = path.join(current, segment);
85
+ let stat;
86
+ try {
87
+ stat = await fs.lstat(current);
88
+ } catch {
89
+ return false; // missing parent — nothing to resolve through
90
+ }
91
+ if (stat.isSymbolicLink()) {
92
+ return true;
93
+ }
94
+ }
95
+ return false;
96
+ }
97
+
67
98
  // Hardcoded fallback for installs that predate file tracking (no 'files' field
68
99
  // in install.json). New installs always have a 'files' list, so this fallback
69
100
  // only applies when upgrading from a very old UKit version.
@@ -205,18 +236,36 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
205
236
  ...linkPaths.map((abs) => ({ abs, useLink: true })),
206
237
  ];
207
238
 
239
+ // Never delete through a symlinked parent — see hasSymlinkedParent. Skipped entries
240
+ // are reported (dryRun and real run alike) so the user sees exactly what was refused.
241
+ const skippedSymlinkParents = [];
242
+ const safeEntries = [];
243
+ for (const entry of allEntries) {
244
+ if (await hasSymlinkedParent(projectRoot, entry.abs)) {
245
+ skippedSymlinkParents.push(entry.abs);
246
+ } else {
247
+ safeEntries.push(entry);
248
+ }
249
+ }
250
+ if (skippedSymlinkParents.length > 0) {
251
+ console.warn(
252
+ '[UKit] Refusing to remove paths whose parent directories are symlinks (deleting them would escape the project root):',
253
+ skippedSymlinkParents,
254
+ );
255
+ }
256
+
208
257
  if (dryRun) {
209
258
  // Use lstat (not access) so broken symlinks are included in the report.
210
259
  const wouldRemove = [];
211
- for (const { abs } of allEntries) {
260
+ for (const { abs } of safeEntries) {
212
261
  if (await pathExistsLstat(abs)) wouldRemove.push(abs);
213
262
  }
214
- return { removed: 0, attempted: allEntries.length, wasInstalled: true, wouldRemove };
263
+ return { removed: 0, attempted: allEntries.length, wasInstalled: true, wouldRemove, skippedSymlinkParents };
215
264
  }
216
265
 
217
266
  // Remove all paths in parallel
218
267
  const results = await Promise.all(
219
- allEntries.map(({ abs, useLink }) => {
268
+ safeEntries.map(({ abs, useLink }) => {
220
269
  const remove = useLink ? removeLinkOnly : removeLinkOrDir;
221
270
  return remove(abs).then((didRemove) => ({ abs, didRemove }));
222
271
  }),
@@ -240,5 +289,5 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
240
289
  await cleanupEmptyParents(removedPath, projectRoot);
241
290
  }
242
291
 
243
- return { removed, attempted: allEntries.length, wasInstalled: true };
292
+ return { removed, attempted: allEntries.length, wasInstalled: true, skippedSymlinkParents };
244
293
  }