@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 +82 -0
- package/package.json +2 -2
- package/src/core/compact/threshold.js +113 -13
- package/src/core/fileOps.js +69 -0
- package/src/core/output/index.js +16 -11
- package/src/core/uninstall.js +53 -4
- package/templates/.claude/hooks/auto-allow-bash.sh +115 -71
- package/templates/.claude/hooks/auto-prune-bash.sh +88 -30
- package/templates/.claude/hooks/context-hardcap-gate.sh +13 -4
- package/templates/.claude/hooks/reset-compact-pressure.sh +117 -13
- package/templates/.claude/hooks/verification-guard.sh +87 -19
- package/templates/.claude/ukit/index/cache-utils.mjs +53 -29
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +128 -17
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +30 -20
- package/templates/.claude/ukit/runtime/output-compression.mjs +29 -19
- package/templates/.claude/ukit/runtime/reinject-context.mjs +30 -4
- package/templates/.claude/ukit/runtime/token-utils.mjs +83 -2
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.
|
|
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.
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1058
|
+
return mutateCompactPressureState(
|
|
1059
|
+
projectRoot,
|
|
1060
|
+
(current) => registerThresholdCompactPlan(current, plan, config),
|
|
1061
|
+
pressureSessionConfig(plan, config),
|
|
1062
|
+
);
|
|
963
1063
|
}
|
package/src/core/fileOps.js
CHANGED
|
@@ -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.
|
package/src/core/output/index.js
CHANGED
|
@@ -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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
entries
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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) {
|
package/src/core/uninstall.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|