@ngockhoale/ukit 2.3.7 → 2.3.9

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,77 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.3.9 - 2026-09-10
6
+
7
+ Freeze-sweep wave 5: fixes for completion-gate loops, worker-agent dead ends, and malformed
8
+ context-cap configuration that could otherwise halt all mutation progress.
9
+
10
+ **P1 — clean `find-cause` audits were forced into a Stop-hook loop.** A recommend-only
11
+ investigation that found no actionable bug still required write and verification evidence, so
12
+ the completion gate blocked the honest outcome and demanded a fabricated edit. The execution
13
+ ledger now permits that narrow clean-audit outcome while retaining normal recovery blocks after
14
+ a failed verification or incomplete mutation.
15
+
16
+ **P1 — routed workers could wait for a user they cannot contact.** The Claude Code and omp
17
+ `bug-debugger` / `feature-implementer` agents instructed workers to “ask the user” on unclear
18
+ or non-reproducible work. They now perform one bounded next diagnostic step and return a
19
+ structured `STATUS: BLOCKED` report with the exact missing artifact or decision to the parent
20
+ agent, which owns user communication.
21
+
22
+ **P1 — malformed context-cap configuration could brick mutations.** Invalid
23
+ `compact.hardCapTokens` could collapse the cap to one token, invalid
24
+ `hardCapGraceCalls` could eliminate the landing window, and wrong-shaped present
25
+ `hardCapBlock` values could unexpectedly leave the hard gate enabled. The shared source/runtime
26
+ threshold logic and Claude hook now accept only positive integer budgets, while malformed
27
+ non-boolean `hardCapBlock` values fail open; the documented absent-key default remains enabled.
28
+ The context-window advisory uses the same hard-cap validation. Regression coverage includes
29
+ negative/zero/fraction/string/boolean/null budgets and malformed toggle values.
30
+
31
+ Full suite: 81 files, 1,396 tests green.
32
+
33
+ ## 2.3.8 - 2026-09-10
34
+
35
+ Freeze-sweep wave 4: every fix in this release targets the "agent silently stops working"
36
+ class — lost permission rules that turn into human-waiting prompts, a session start that
37
+ wipes a sibling session's near-cap warnings, torn state files, and lost concurrency
38
+ bookkeeping. All found by verified reproducers and fixed with regression coverage.
39
+
40
+ **P1 — SessionStart wiped a sibling session's compact-pressure state.** The SessionStart
41
+ reset hook deleted the whole project-wide `compact-pressure.json`, so starting a second
42
+ session in the same project zeroed the first session's pressure history: its near-cap
43
+ warnings stopped firing, it ran into the real context cap, and it stalled. Pressure state
44
+ is now a session-keyed document (`{"v":2,"sessions":{<session_id>:{…}}}`) with the newest
45
+ record's flat fields still projected at the top level for raw readers. Every writer and
46
+ reader threads the hook payload's `session_id` (compact-threshold CLI, output-compression,
47
+ reinject-context PreCompact, context-hardcap-gate); a brand-new session id starts clean
48
+ and never inherits a sibling's totals; the reset hook removes only the calling session's
49
+ record; sessions are pruned to the 8 most recent; a legacy flat file upgrades in place.
50
+ Ships in both `src/core/compact/threshold.js` and the runtime mirror.
51
+
52
+ **P1 — concurrent auto-allow invocations lost Bash permission rules.** The hook's
53
+ read-modify-write of `settings.local.json` and `permission-usage.json` was unlocked, and
54
+ 30 concurrent invocations kept only 26/30 rules and 23/30 usage records — a lost rule
55
+ means the next run of that command prompts for permission again, i.e. the agent sits
56
+ waiting for a human click. Both files are now written inside one mkdir lock on
57
+ `<permission-usage.json>.lock` (protocol-compatible with `withFileLock`, fail-open ≤5s,
58
+ parent directory created before locking) with atomic tmp+rename writes, so Claude Code
59
+ can never observe a torn settings file either — a torn parse would drop the entire allow
60
+ list and prompt on every command. `auto-prune-bash.sh` now shares the same lock and
61
+ atomic writes.
62
+
63
+ **P2 — index cache writes lost entries under concurrency.** `cache-utils.mjs`
64
+ read-modify-write cycles were unlocked: 20 concurrent writers kept 2 entries. All cache
65
+ reads-modify-writes (write + touch paths) now run under `withFileLock` with atomic
66
+ compact writes (cache files stay single-line JSON by contract).
67
+
68
+ **P2 — verification-progress and execution-ledger counters lost concurrent updates.**
69
+ `verification-guard.sh` now records attempts through a locked re-read + merge (sync
70
+ `Atomics.wait` backoff so the hook's `process.exit` flow is untouched) and writes
71
+ atomically; `execution-ledger.mjs`'s continuation/notified counters re-read inside
72
+ `withFileLock` so parallel subagent Stop hooks cannot reset each other's budgets.
73
+
74
+ Full suite: 81 files, 1,373 tests green, including two shuffled runs.
75
+
5
76
  ## 2.3.7 - 2026-09-10
6
77
 
7
78
  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.9",
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",
@@ -35,6 +35,15 @@ function finiteNumber(value, fallback = 0) {
35
35
  return Number.isFinite(number) ? number : fallback;
36
36
  }
37
37
 
38
+ // A malformed hard cap must fail back to a safe ceiling, never collapse to one token and
39
+ // brick every Edit/Write/Bash call. Unlike token estimates, configuration is an operator
40
+ // contract: accept only whole positive token counts.
41
+ function positiveInteger(value, fallback) {
42
+ return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0
43
+ ? value
44
+ : fallback;
45
+ }
46
+
38
47
  function uniqueStrings(values) {
39
48
  const seen = new Set();
40
49
  const unique = [];
@@ -434,11 +443,15 @@ export function buildCompactThresholds(config = {}) {
434
443
  );
435
444
  const hardThreshold = Math.max(softThreshold + 1, Math.round(softThreshold * 1.6));
436
445
  const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
446
+ // Keep the source mirror's config contract aligned with the installed runtime: malformed
447
+ // caps fall back to a safe ceiling rather than turning every mutation into an over-cap call.
448
+ const hardCapTokens = positiveInteger(config?.compact?.hardCapTokens, loadShippedCompactBudget().hardCapTokens);
437
449
 
438
450
  return {
439
451
  softThreshold,
440
452
  hardThreshold,
441
453
  baselineTokens,
454
+ hardCapTokens,
442
455
  };
443
456
  }
444
457
 
@@ -553,7 +566,72 @@ export function resolveThresholdCompactBudget({
553
566
  };
554
567
  }
555
568
 
569
+ // ---- session-scoped pressure document ---------------------------------------
570
+ // compact-pressure.json holds ONE record per session id. A session starting must never
571
+ // zero another live session's pressure bookkeeping (its hard-cap gate and compact
572
+ // advisories), and a brand-new session must not inherit a sibling's totals. The newest
573
+ // record's flat fields stay projected at the document's top level so readers that predate
574
+ // this shape (raw JSON readers) still see a coherent state.
575
+ // Mirrors templates/.claude/ukit/runtime/compact-threshold.mjs — keep in lockstep.
576
+ const PRESSURE_SESSIONS_MAX = 8;
577
+
578
+ function normalizeSessionId(value) {
579
+ const id = typeof value === 'string' ? value.trim() : '';
580
+ return id || null;
581
+ }
582
+
583
+ function readPressureDocument(raw) {
584
+ if (raw && typeof raw === 'object' && raw.sessions && typeof raw.sessions === 'object') {
585
+ const sessions = {};
586
+ for (const [id, record] of Object.entries(raw.sessions)) {
587
+ if (id && record && typeof record === 'object') {
588
+ sessions[id] = record;
589
+ }
590
+ }
591
+ return sessions;
592
+ }
593
+ // Legacy single-record file: preserve the in-flight session under a shared bucket.
594
+ return raw && typeof raw === 'object' ? { default: raw } : {};
595
+ }
596
+
597
+ function pickPressureSession(sessions, sessionId) {
598
+ if (sessionId) {
599
+ // A session id that has no record yet starts clean — it must not inherit another
600
+ // session's totals, and the other way round nothing here touches that session.
601
+ return { id: sessionId, record: sessions[sessionId] ?? null };
602
+ }
603
+ const entries = Object.entries(sessions);
604
+ if (!entries.length) {
605
+ return { id: 'default', record: null };
606
+ }
607
+ const newest = entries
608
+ .sort(([, left], [, right]) => finiteNumber(right?.updatedAt, 0) - finiteNumber(left?.updatedAt, 0))[0];
609
+ return { id: newest[0], record: newest[1] };
610
+ }
611
+
612
+ function resolvePressureRecord(raw, config) {
613
+ if (!raw || typeof raw !== 'object' || !raw.sessions || typeof raw.sessions !== 'object') {
614
+ return raw; // flat legacy state — normalized as-is below
615
+ }
616
+ const { record } = pickPressureSession(readPressureDocument(raw), normalizeSessionId(config?.sessionId));
617
+ return record;
618
+ }
619
+
620
+ function projectPressureDocument(sessions) {
621
+ const newestFirst = Object.entries(sessions)
622
+ .sort(([, left], [, right]) => finiteNumber(right?.updatedAt, 0) - finiteNumber(left?.updatedAt, 0))
623
+ .slice(0, PRESSURE_SESSIONS_MAX);
624
+ const newestRecord = newestFirst[0]?.[1] ?? null;
625
+ return {
626
+ ...(newestRecord && typeof newestRecord === 'object' ? newestRecord : {}),
627
+ updatedAt: finiteNumber(newestRecord?.updatedAt, Date.now()),
628
+ v: 2,
629
+ sessions: Object.fromEntries(newestFirst),
630
+ };
631
+ }
632
+
556
633
  export function buildCompactPressureState(rawState = null, config = {}) {
634
+ rawState = resolvePressureRecord(rawState, config);
557
635
  const thresholds = buildCompactThresholds(config);
558
636
  const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
559
637
  const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
@@ -940,13 +1018,18 @@ export async function readCompactPressureState(projectRoot, config = {}) {
940
1018
  // All compact-pressure mutations share one lock on the state file: without it,
941
1019
  // concurrent hook processes (parallel subagents) and same-process flows interleave
942
1020
  // their read-modify-write cycles and silently drop each other's sessionTokens.
1021
+ // Mutations are session-scoped: only the calling session's record is rewritten;
1022
+ // sibling session records pass through untouched.
943
1023
  async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
944
1024
  const runtimePaths = buildRuntimePaths(projectRoot);
945
1025
  return withFileLock(runtimePaths.compactPressurePath, async () => {
946
- const current = buildCompactPressureState(await readJsonIfExists(runtimePaths.compactPressurePath), config);
1026
+ const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
1027
+ const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1028
+ const current = buildCompactPressureState(record, config);
947
1029
  const next = mutator(current);
948
1030
  const normalized = buildCompactPressureState(next, config);
949
- await writeJson(runtimePaths.compactPressurePath, normalized);
1031
+ sessions[id] = { ...normalized, updatedAt: Date.now() };
1032
+ await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
950
1033
  return normalized;
951
1034
  });
952
1035
  }
@@ -954,20 +1037,40 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
954
1037
  export async function writeCompactPressureState(projectRoot, state, config = {}) {
955
1038
  const runtimePaths = buildRuntimePaths(projectRoot);
956
1039
  return withFileLock(runtimePaths.compactPressurePath, async () => {
1040
+ const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
1041
+ const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
957
1042
  const normalized = buildCompactPressureState(state, config);
958
- await writeJson(runtimePaths.compactPressurePath, normalized);
1043
+ sessions[id] = { ...normalized, updatedAt: Date.now() };
1044
+ await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
959
1045
  return normalized;
960
1046
  });
961
1047
  }
962
1048
 
1049
+ function pressureSessionConfig(payload = {}, config = {}) {
1050
+ const sessionId = normalizeSessionId(payload?.sessionId) ?? normalizeSessionId(config?.sessionId);
1051
+ return sessionId ? { ...config, sessionId } : config;
1052
+ }
1053
+
963
1054
  export async function updateCompactPressureFromPrompt(projectRoot, payload, config = {}) {
964
- return mutateCompactPressureState(projectRoot, (current) => registerPromptPressure(current, payload, config), config);
1055
+ return mutateCompactPressureState(
1056
+ projectRoot,
1057
+ (current) => registerPromptPressure(current, payload, config),
1058
+ pressureSessionConfig(payload, config),
1059
+ );
965
1060
  }
966
1061
 
967
1062
  export async function updateCompactPressureFromOutput(projectRoot, payload, config = {}) {
968
- return mutateCompactPressureState(projectRoot, (current) => registerOutputPressure(current, payload, config), config);
1063
+ return mutateCompactPressureState(
1064
+ projectRoot,
1065
+ (current) => registerOutputPressure(current, payload, config),
1066
+ pressureSessionConfig(payload, config),
1067
+ );
969
1068
  }
970
1069
 
971
1070
  export async function writeThresholdCompactPlan(projectRoot, plan, config = {}) {
972
- return mutateCompactPressureState(projectRoot, (current) => registerThresholdCompactPlan(current, plan, config), config);
1071
+ return mutateCompactPressureState(
1072
+ projectRoot,
1073
+ (current) => registerThresholdCompactPlan(current, plan, config),
1074
+ pressureSessionConfig(plan, config),
1075
+ );
973
1076
  }
@@ -18,7 +18,7 @@ Systematic debugging — understand before fixing.
18
18
 
19
19
  - Run the failing command/action.
20
20
  - Capture exact error message and stack trace.
21
- - If not reproducible → document conditions and ask user.
21
+ - If not reproducible → document conditions, run the next most-discriminating bounded repro, then report any exact missing user-only artifact/permission to the parent agent. Never wait for or ask a user directly — you are a worker.
22
22
 
23
23
  ### 2. Trace Root Cause
24
24
 
@@ -83,4 +83,4 @@ Daily mode: skip. Handoff mode: set task status `pending_review` in `INDEX.md`;
83
83
  - non-trivial bug: `docs/MEMORY.md` + `docs/PROJECT.md` + `docs/CODE_MAP.md`
84
84
  - read `docs/WORKLOG.md` only recent relevant entries
85
85
  - Keep fix scope minimal — no drive-by refactors.
86
- - If root cause is unclear after 5 minutes of tracing → ask user for more context.
86
+ - If root cause is unclear after 5 minutes of tracing → try one bounded alternative hypothesis/repro, then report precise evidence plus the smallest needed missing context to the parent agent. Never wait for or ask a user directly — the parent owns user communication.
@@ -12,7 +12,9 @@ Implement requested behavior with minimal scope drift.
12
12
  - **Daily/ad-hoc mode** (DEFAULT): task didn't come from `docs/AI_HANDOFF/` → use the original lightweight workflow. Tests only when touched code already has coverage. No reviewer trigger.
13
13
  - **Handoff mode**: task file is `docs/AI_HANDOFF/tasks/TASK-xxx.md` OR user explicitly invokes handoff (e.g. "execute task TASK-001") → activate full Quality Gate: test-first → green → reviewer.
14
14
 
15
- If unsure which mode applies, ask the user. Don't apply Handoff mode rules to a quick one-off fix.
15
+ If unsure which mode applies, default to Daily/ad-hoc mode unless the task path or prompt
16
+ explicitly selects Handoff. Do not ask a user — you are a worker; report the ambiguity and
17
+ reasoning to the parent agent so it can decide whether to re-route.
16
18
 
17
19
  **In Handoff mode you are running unattended — ask nothing.** You were spawned by an
18
20
  orchestrator driving a pipeline; there is no human in your conversation to answer, and a
@@ -33,7 +35,7 @@ and even then, report it, don't ask about it.
33
35
  - non-trivial: `docs/MEMORY.md` + `docs/PROJECT.md` + `docs/CODE_MAP.md`
34
36
  - Identify target files and existing patterns.
35
37
  - If task came from handoff, read `tasks/TASK-xxx.md` and locate its **Test Plan** + **Verification Commands**.
36
- - Daily mode: if confidence is low or risk is high, ask one short clarifying question before deeper analysis. Handoff mode: do not ask — decide and record the decision (see above).
38
+ - Daily mode: if confidence is low or risk is high, inspect the next bounded source/context signal and hand back a concise `STATUS: BLOCKED` report with the exact missing decision or artifact if confidence remains low. Do not ask a user directly. Handoff mode: do not ask — decide and record the decision (see above).
37
39
 
38
40
  ### 2. Plan Approach (< 1 minute)
39
41
 
@@ -44,7 +46,7 @@ and even then, report it, don't ask about it.
44
46
 
45
47
  - Write the test(s) from §2 / from task Test Plan.
46
48
  - Run them: must FAIL for the expected reason. Capture output.
47
- - If test passes immediately → test is wrong or behavior already exists. Fix the test or stop and report.
49
+ - If test passes immediately → test is wrong or behavior already exists. Fix the test; if the intended behavior cannot be inferred, hand back `STATUS: BLOCKED` with the observed behavior and the smallest decision the parent must resolve. Never silently stop.
48
50
  - **Daily mode**: skip this step unless touched code already has tests (then follow original rule).
49
51
 
50
52
 
@@ -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