@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 +71 -0
- package/package.json +1 -1
- package/src/core/compact/threshold.js +109 -6
- package/templates/.claude/agents/bug-debugger.md +2 -2
- package/templates/.claude/agents/feature-implementer.md +5 -3
- 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 +28 -7
- package/templates/.claude/hooks/context-window-guard.sh +1 -1
- 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 +120 -11
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +52 -20
- package/templates/.claude/ukit/runtime/output-compression.mjs +10 -2
- package/templates/.claude/ukit/runtime/reinject-context.mjs +30 -4
- package/templates/.omp/agents/bug-debugger.md +2 -2
- package/templates/.omp/agents/feature-implementer.md +5 -3
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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
168
|
+
const usage = readJson(usagePath, {});
|
|
169
|
+
if (!usage.managedRules || typeof usage.managedRules !== "object") {
|
|
170
|
+
usage.managedRules = {};
|
|
171
|
+
}
|
|
138
172
|
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
firstSeen: prevMeta.firstSeen || nowUtc,
|
|
142
|
-
lastSeen: nowUtc,
|
|
143
|
-
};
|
|
173
|
+
const allow = settings.permissions.allow;
|
|
174
|
+
let settingsChanged = false;
|
|
144
175
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
176
|
+
if (!allow.includes(rule)) {
|
|
177
|
+
allow.push(rule);
|
|
178
|
+
settingsChanged = true;
|
|
179
|
+
appendAudit("auto_add", { rule });
|
|
180
|
+
}
|
|
150
181
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 (
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
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
|