@ngockhoale/ukit 2.3.7 → 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,49 @@
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
+
5
48
  ## 2.3.7 - 2026-09-10
6
49
 
7
50
  Bug-sweep wave 3: a data-loss escape in uninstall, cross-process races on shared runtime
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.3.7",
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",
@@ -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);
@@ -940,13 +1005,18 @@ export async function readCompactPressureState(projectRoot, config = {}) {
940
1005
  // All compact-pressure mutations share one lock on the state file: without it,
941
1006
  // concurrent hook processes (parallel subagents) and same-process flows interleave
942
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.
943
1010
  async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
944
1011
  const runtimePaths = buildRuntimePaths(projectRoot);
945
1012
  return withFileLock(runtimePaths.compactPressurePath, async () => {
946
- const current = buildCompactPressureState(await readJsonIfExists(runtimePaths.compactPressurePath), config);
1013
+ const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
1014
+ const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1015
+ const current = buildCompactPressureState(record, config);
947
1016
  const next = mutator(current);
948
1017
  const normalized = buildCompactPressureState(next, config);
949
- await writeJson(runtimePaths.compactPressurePath, normalized);
1018
+ sessions[id] = { ...normalized, updatedAt: Date.now() };
1019
+ await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
950
1020
  return normalized;
951
1021
  });
952
1022
  }
@@ -954,20 +1024,40 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
954
1024
  export async function writeCompactPressureState(projectRoot, state, config = {}) {
955
1025
  const runtimePaths = buildRuntimePaths(projectRoot);
956
1026
  return withFileLock(runtimePaths.compactPressurePath, async () => {
1027
+ const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
1028
+ const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
957
1029
  const normalized = buildCompactPressureState(state, config);
958
- await writeJson(runtimePaths.compactPressurePath, normalized);
1030
+ sessions[id] = { ...normalized, updatedAt: Date.now() };
1031
+ await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
959
1032
  return normalized;
960
1033
  });
961
1034
  }
962
1035
 
1036
+ function pressureSessionConfig(payload = {}, config = {}) {
1037
+ const sessionId = normalizeSessionId(payload?.sessionId) ?? normalizeSessionId(config?.sessionId);
1038
+ return sessionId ? { ...config, sessionId } : config;
1039
+ }
1040
+
963
1041
  export async function updateCompactPressureFromPrompt(projectRoot, payload, config = {}) {
964
- return mutateCompactPressureState(projectRoot, (current) => registerPromptPressure(current, payload, config), config);
1042
+ return mutateCompactPressureState(
1043
+ projectRoot,
1044
+ (current) => registerPromptPressure(current, payload, config),
1045
+ pressureSessionConfig(payload, config),
1046
+ );
965
1047
  }
966
1048
 
967
1049
  export async function updateCompactPressureFromOutput(projectRoot, payload, config = {}) {
968
- return mutateCompactPressureState(projectRoot, (current) => registerOutputPressure(current, payload, config), config);
1050
+ return mutateCompactPressureState(
1051
+ projectRoot,
1052
+ (current) => registerOutputPressure(current, payload, config),
1053
+ pressureSessionConfig(payload, config),
1054
+ );
969
1055
  }
970
1056
 
971
1057
  export async function writeThresholdCompactPlan(projectRoot, plan, config = {}) {
972
- return mutateCompactPressureState(projectRoot, (current) => registerThresholdCompactPlan(current, plan, config), config);
1058
+ return mutateCompactPressureState(
1059
+ projectRoot,
1060
+ (current) => registerThresholdCompactPlan(current, plan, config),
1061
+ pressureSessionConfig(plan, config),
1062
+ );
973
1063
  }
@@ -1,6 +1,6 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: auto-add Bash(<binary>:*) allow rules for frequently used commands.
3
- # Optimized: fast-path grep to skip Node.js when rule already exists.
3
+ # Settings/usage updates run as ONE locked, atomic read-modify-write (see node block).
4
4
 
5
5
  INPUT=$(cat)
6
6
  # jq is absent on stock macOS. Keep jq as the low-latency normal path, but fall back to
@@ -61,30 +61,22 @@ RULE="Bash(${BIN}:*)"
61
61
  NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
62
62
  MAX_RULES="${CLAUDE_AUTO_ALLOW_MAX_RULES:-150}"
63
63
 
64
- # Fast path: if rule already exists in allow list, just update usage and exit
65
- if [ -f "$SETTINGS_LOCAL" ] && grep -Fq -- "\"$RULE\"" "$SETTINGS_LOCAL" 2>/dev/null; then
66
- # Only update usage timestamp (lighter than full Node.js logic)
67
- node -e '
68
- const fs = require("fs");
69
- const usagePath = process.argv[1];
70
- const rule = process.argv[2];
71
- const nowUtc = process.argv[3];
72
- if (!fs.existsSync(usagePath)) {
73
- fs.mkdirSync(require("path").dirname(usagePath), { recursive: true });
74
- fs.writeFileSync(usagePath, "{}");
75
- }
76
- try {
77
- const usage = JSON.parse(fs.readFileSync(usagePath, "utf8"));
78
- if (!usage.managedRules) usage.managedRules = {};
79
- const prev = usage.managedRules[rule] || {};
80
- usage.managedRules[rule] = { firstSeen: prev.firstSeen || nowUtc, lastSeen: nowUtc };
81
- fs.writeFileSync(usagePath, JSON.stringify(usage, null, 2) + "\n");
82
- } catch {}
83
- ' "$USAGE_FILE" "$RULE" "$NOW_UTC" >/dev/null 2>&1 || true
84
- exit 0
85
- fi
86
-
87
- # Slow path: rule is new, need full Node.js processing
64
+ # Single locked read-modify-write for settings.local.json + permission-usage.json.
65
+ #
66
+ # Why the lock: parallel hook invocations (main session + subagents) each used to read a
67
+ # stale snapshot and rewrite the whole file, silently dropping each other's allow rules
68
+ # (verified: 30 concurrent invocations kept 26/30 rules and 23/30 usage records). A lost
69
+ # rule means the NEXT invocation of that command prompts for permission again — the agent
70
+ # stalls waiting for a human click. The lock protocol below is protocol-compatible with
71
+ # runtime token-utils withFileLock / src/core/fileOps.js (mkdir-based `<file>.lock`),
72
+ # with the usage file as the single serialization point for both files this hook writes.
73
+ #
74
+ # Why atomic writes: Claude Code re-reads settings.local.json to decide permissions.
75
+ # A torn/partial write would fail JSON parsing and drop the ENTIRE allow list — every
76
+ # command would prompt. tmp+rename makes readers see old-or-new, never half.
77
+ #
78
+ # Liveness over strictness: if the lock cannot be acquired within maxWaitMs the mutation
79
+ # runs anyway (pre-lock behaviour) — this hook must never hang a PreToolUse chain.
88
80
  node -e '
89
81
  const fs = require("fs");
90
82
  const path = require("path");
@@ -105,66 +97,118 @@ function readJson(filePath, fallback) {
105
97
  }
106
98
  }
107
99
 
100
+ function writeJsonAtomic(filePath, value) {
101
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
102
+ const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
103
+ try {
104
+ fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + "\n");
105
+ fs.renameSync(tempPath, filePath);
106
+ } catch (error) {
107
+ try {
108
+ fs.rmSync(tempPath, { force: true });
109
+ } catch {}
110
+ throw error;
111
+ }
112
+ }
113
+
108
114
  function appendAudit(event, payload) {
109
115
  fs.appendFileSync(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
110
116
  }
111
117
 
112
- const settingsDir = path.dirname(settingsPath);
113
- const usageDir = path.dirname(usagePath);
114
- fs.mkdirSync(settingsDir, { recursive: true });
115
- fs.mkdirSync(usageDir, { recursive: true });
116
-
117
- const settings = readJson(settingsPath, {});
118
- if (!settings.permissions || typeof settings.permissions !== "object") {
119
- settings.permissions = {};
120
- }
121
- if (!Array.isArray(settings.permissions.allow)) {
122
- settings.permissions.allow = [];
118
+ async function withLock(lockPath, fn) {
119
+ const staleMs = 10000;
120
+ const maxWaitMs = 5000;
121
+ const startedAt = Date.now();
122
+ let held = false;
123
+ while (!held) {
124
+ try {
125
+ fs.mkdirSync(lockPath);
126
+ held = true;
127
+ } catch (error) {
128
+ if (!error || error.code !== "EEXIST") return fn();
129
+ try {
130
+ const stat = fs.statSync(lockPath);
131
+ if (Date.now() - stat.mtimeMs > staleMs) {
132
+ try {
133
+ fs.rmSync(lockPath, { recursive: true, force: true });
134
+ continue;
135
+ } catch {}
136
+ }
137
+ } catch {}
138
+ if (Date.now() - startedAt > maxWaitMs) break;
139
+ await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
140
+ }
141
+ }
142
+ try {
143
+ return await fn();
144
+ } finally {
145
+ if (held) {
146
+ try {
147
+ fs.rmdirSync(lockPath);
148
+ } catch {}
149
+ }
150
+ }
123
151
  }
124
152
 
125
- const usage = readJson(usagePath, {});
126
- if (!usage.managedRules || typeof usage.managedRules !== "object") {
127
- usage.managedRules = {};
128
- }
153
+ // The lock directory lives beside the usage file, so its parent must exist BEFORE the
154
+ // lock is taken — a first-ever run in a fresh project would otherwise fail mkdir with
155
+ // ENOENT, fail open, and race.
156
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
157
+ fs.mkdirSync(path.dirname(usagePath), { recursive: true });
129
158
 
130
- const allow = settings.permissions.allow;
131
- let changed = false;
159
+ withLock(`${usagePath}.lock`, () => {
160
+ const settings = readJson(settingsPath, {});
161
+ if (!settings.permissions || typeof settings.permissions !== "object") {
162
+ settings.permissions = {};
163
+ }
164
+ if (!Array.isArray(settings.permissions.allow)) {
165
+ settings.permissions.allow = [];
166
+ }
132
167
 
133
- if (!allow.includes(rule)) {
134
- allow.push(rule);
135
- changed = true;
136
- appendAudit("auto_add", { rule });
137
- }
168
+ const usage = readJson(usagePath, {});
169
+ if (!usage.managedRules || typeof usage.managedRules !== "object") {
170
+ usage.managedRules = {};
171
+ }
138
172
 
139
- const prevMeta = usage.managedRules[rule] || {};
140
- usage.managedRules[rule] = {
141
- firstSeen: prevMeta.firstSeen || nowUtc,
142
- lastSeen: nowUtc,
143
- };
173
+ const allow = settings.permissions.allow;
174
+ let settingsChanged = false;
144
175
 
145
- const managedSorted = Object.entries(usage.managedRules).sort((a, b) => {
146
- const ta = Date.parse(a[1]?.lastSeen || "") || 0;
147
- const tb = Date.parse(b[1]?.lastSeen || "") || 0;
148
- return tb - ta;
149
- });
176
+ if (!allow.includes(rule)) {
177
+ allow.push(rule);
178
+ settingsChanged = true;
179
+ appendAudit("auto_add", { rule });
180
+ }
150
181
 
151
- if (managedSorted.length > maxRules) {
152
- const evict = managedSorted.slice(maxRules);
153
- for (const [evictRule] of evict) {
154
- delete usage.managedRules[evictRule];
155
- const idx = allow.indexOf(evictRule);
156
- if (idx !== -1) {
157
- allow.splice(idx, 1);
158
- changed = true;
182
+ const prevMeta = usage.managedRules[rule] || {};
183
+ usage.managedRules[rule] = {
184
+ firstSeen: prevMeta.firstSeen || nowUtc,
185
+ lastSeen: nowUtc,
186
+ };
187
+
188
+ const managedSorted = Object.entries(usage.managedRules).sort((a, b) => {
189
+ const ta = Date.parse(a[1]?.lastSeen || "") || 0;
190
+ const tb = Date.parse(b[1]?.lastSeen || "") || 0;
191
+ return tb - ta;
192
+ });
193
+
194
+ if (managedSorted.length > maxRules) {
195
+ const evict = managedSorted.slice(maxRules);
196
+ for (const [evictRule] of evict) {
197
+ delete usage.managedRules[evictRule];
198
+ const idx = allow.indexOf(evictRule);
199
+ if (idx !== -1) {
200
+ allow.splice(idx, 1);
201
+ settingsChanged = true;
202
+ }
203
+ appendAudit("auto_prune_cap", { rule: evictRule, maxRules });
159
204
  }
160
- appendAudit("auto_prune_cap", { rule: evictRule, maxRules });
161
205
  }
162
- }
163
206
 
164
- if (changed) {
165
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
166
- }
167
- fs.writeFileSync(usagePath, JSON.stringify(usage, null, 2) + "\n");
207
+ if (settingsChanged) {
208
+ writeJsonAtomic(settingsPath, settings);
209
+ }
210
+ writeJsonAtomic(usagePath, usage);
211
+ }).catch(() => {});
168
212
  ' "$SETTINGS_LOCAL" "$USAGE_FILE" "$AUDIT_FILE" "$RULE" "$NOW_UTC" "$MAX_RULES" >/dev/null 2>&1 || true
169
213
 
170
214
  exit 0
@@ -15,6 +15,7 @@ NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
15
15
 
16
16
  node -e '
17
17
  const fs = require("fs");
18
+ const path = require("path");
18
19
 
19
20
  const settingsPath = process.argv[1];
20
21
  const usagePath = process.argv[2];
@@ -31,45 +32,102 @@ function readJson(filePath) {
31
32
  }
32
33
  }
33
34
 
35
+ function writeJsonAtomic(filePath, value) {
36
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
37
+ const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
38
+ try {
39
+ fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + "\n");
40
+ fs.renameSync(tempPath, filePath);
41
+ } catch (error) {
42
+ try {
43
+ fs.rmSync(tempPath, { force: true });
44
+ } catch {}
45
+ throw error;
46
+ }
47
+ }
48
+
34
49
  function appendAudit(event, payload) {
35
50
  fs.appendFileSync(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
36
51
  }
37
52
 
38
- const settings = readJson(settingsPath);
39
- const usage = readJson(usagePath);
40
- if (!settings || !usage) process.exit(0);
41
- if (!settings.permissions || typeof settings.permissions !== "object") process.exit(0);
42
- if (!Array.isArray(settings.permissions.allow)) process.exit(0);
43
- if (!usage.managedRules || typeof usage.managedRules !== "object") process.exit(0);
44
-
45
- const allow = settings.permissions.allow;
46
- let changedSettings = false;
47
- let prunedCount = 0;
48
-
49
- for (const [rule, meta] of Object.entries(usage.managedRules)) {
50
- const lastSeen = Date.parse(meta?.lastSeen || "");
51
- if (!Number.isFinite(lastSeen) || lastSeen >= cutoff) continue;
52
-
53
- const idx = allow.indexOf(rule);
54
- if (idx !== -1) {
55
- allow.splice(idx, 1);
56
- changedSettings = true;
53
+ // Same lock point as auto-allow-bash.sh (<usage>.lock): the two hooks serialize their
54
+ // settings/usage rewrites against each other and against parallel invocations. Atomic
55
+ // writes because Claude Code re-reads settings.local.json — a torn write would drop the
56
+ // ENTIRE allow list and every command would prompt again. Fail-open after 5s: a hook
57
+ // must never hang the chain.
58
+ async function withLock(lockPath, fn) {
59
+ const staleMs = 10000;
60
+ const maxWaitMs = 5000;
61
+ const startedAt = Date.now();
62
+ let held = false;
63
+ while (!held) {
64
+ try {
65
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
66
+ fs.mkdirSync(lockPath);
67
+ held = true;
68
+ } catch (error) {
69
+ if (!error || error.code !== "EEXIST") return fn();
70
+ try {
71
+ const stat = fs.statSync(lockPath);
72
+ if (Date.now() - stat.mtimeMs > staleMs) {
73
+ try {
74
+ fs.rmSync(lockPath, { recursive: true, force: true });
75
+ continue;
76
+ } catch {}
77
+ }
78
+ } catch {}
79
+ if (Date.now() - startedAt > maxWaitMs) break;
80
+ await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
81
+ }
82
+ }
83
+ try {
84
+ return await fn();
85
+ } finally {
86
+ if (held) {
87
+ try {
88
+ fs.rmdirSync(lockPath);
89
+ } catch {}
90
+ }
57
91
  }
58
- delete usage.managedRules[rule];
59
- prunedCount += 1;
60
- appendAudit("auto_prune_ttl", { rule, ttlDays });
61
92
  }
62
93
 
63
- usage.lastPruneAt = nowUtc;
94
+ withLock(`${usagePath}.lock`, async () => {
95
+ const settings = readJson(settingsPath);
96
+ const usage = readJson(usagePath);
97
+ if (!settings || !usage) return;
98
+ if (!settings.permissions || typeof settings.permissions !== "object") return;
99
+ if (!Array.isArray(settings.permissions.allow)) return;
100
+ if (!usage.managedRules || typeof usage.managedRules !== "object") return;
64
101
 
65
- if (changedSettings) {
66
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
67
- }
68
- fs.writeFileSync(usagePath, JSON.stringify(usage, null, 2) + "\n");
102
+ const allow = settings.permissions.allow;
103
+ let changedSettings = false;
104
+ let prunedCount = 0;
69
105
 
70
- if (prunedCount > 0) {
71
- appendAudit("auto_prune_summary", { prunedCount, ttlDays });
72
- }
106
+ for (const [rule, meta] of Object.entries(usage.managedRules)) {
107
+ const lastSeen = Date.parse(meta?.lastSeen || "");
108
+ if (!Number.isFinite(lastSeen) || lastSeen >= cutoff) continue;
109
+
110
+ const idx = allow.indexOf(rule);
111
+ if (idx !== -1) {
112
+ allow.splice(idx, 1);
113
+ changedSettings = true;
114
+ }
115
+ delete usage.managedRules[rule];
116
+ prunedCount += 1;
117
+ appendAudit("auto_prune_ttl", { rule, ttlDays });
118
+ }
119
+
120
+ usage.lastPruneAt = nowUtc;
121
+
122
+ if (changedSettings) {
123
+ writeJsonAtomic(settingsPath, settings);
124
+ }
125
+ writeJsonAtomic(usagePath, usage);
126
+
127
+ if (prunedCount > 0) {
128
+ appendAudit("auto_prune_summary", { prunedCount, ttlDays });
129
+ }
130
+ }).catch(() => {});
73
131
  ' "$SETTINGS_LOCAL" "$USAGE_FILE" "$AUDIT_FILE" "$TTL_DAYS" "$NOW_UTC" >/dev/null 2>&1 || true
74
132
 
75
133
  exit 0
@@ -87,6 +87,15 @@ function readRunCursor() {
87
87
 
88
88
  (async () => {
89
89
  const config = readJsonSafe(path.join(projectRoot, '.ukit', 'storage', 'config.json'), {}) || {};
90
+ // Session-scoped pressure bookkeeping: the gate decides for THIS session's record, so a
91
+ // sibling session starting (SessionStart reset) or compacting never trips or clears
92
+ // this gate for the wrong session.
93
+ const sessionConfig = {
94
+ ...config,
95
+ sessionId: typeof payload.session_id === 'string' && payload.session_id.trim()
96
+ ? payload.session_id.trim()
97
+ : undefined,
98
+ };
90
99
  if (config?.compact?.hardCapBlock === false) {
91
100
  process.exit(0);
92
101
  return;
@@ -111,14 +120,14 @@ function readRunCursor() {
111
120
  // bricks a healthy session at this cap. The transcript's own compact_boundary entries
112
121
  // are the reliable record of real compactions, so sync before deciding, and persist the
113
122
  // reset so it re-arms exactly once per boundary instead of disabling the gate.
114
- let state = mod.buildCompactPressureState(rawState, config);
123
+ let state = mod.buildCompactPressureState(rawState, sessionConfig);
115
124
  try {
116
- const synced = await mod.syncCompactPressureStateWithTranscript(rawState, config, payload.transcript_path);
125
+ const synced = await mod.syncCompactPressureStateWithTranscript(state, sessionConfig, payload.transcript_path);
117
126
  if (synced.reset) {
118
- state = await mod.writeCompactPressureState(projectRoot, synced.state, config);
127
+ state = await mod.writeCompactPressureState(projectRoot, synced.state, sessionConfig);
119
128
  }
120
129
  } catch {
121
- state = mod.buildCompactPressureState(rawState, config);
130
+ state = mod.buildCompactPressureState(rawState, sessionConfig);
122
131
  }
123
132
  const thresholds = mod.buildCompactThresholds(config);
124
133