@ngockhoale/ukit 2.4.2 → 2.4.3

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.
Files changed (43) hide show
  1. package/manifests/platform.full.yaml +19 -111
  2. package/package.json +2 -1
  3. package/scripts/index/refresh-index.mjs +47 -22
  4. package/src/core/compact/threshold.js +36 -6
  5. package/src/diagnostics/classifyHang.js +246 -0
  6. package/src/index/buildIndex.js +1033 -62
  7. package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
  8. package/templates/.claude/hooks/block-dangerous.sh +31 -5
  9. package/templates/.claude/hooks/completion-gate.sh +51 -10
  10. package/templates/.claude/hooks/compress-output.sh +38 -6
  11. package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
  12. package/templates/.claude/hooks/context-window-guard.sh +128 -18
  13. package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
  14. package/templates/.claude/hooks/handoff-resume.sh +31 -5
  15. package/templates/.claude/hooks/post-edit-verify.sh +31 -5
  16. package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
  17. package/templates/.claude/hooks/protect-files.sh +31 -5
  18. package/templates/.claude/hooks/record-execution.sh +31 -5
  19. package/templates/.claude/hooks/sensitive-data-guard.sh +44 -8
  20. package/templates/.claude/hooks/skill-router.sh +31 -5
  21. package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
  22. package/templates/.claude/hooks/task-watchdog.sh +108 -123
  23. package/templates/.claude/hooks/verification-guard.sh +107 -112
  24. package/templates/.claude/hooks/vision-router.sh +49 -13
  25. package/templates/.claude/settings.json +0 -5
  26. package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
  27. package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
  28. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  29. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  30. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  31. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  32. package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
  33. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  34. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  35. package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
  36. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  37. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  38. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  39. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  40. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  41. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  42. package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
  43. package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ // TASK-028: the one async lock utility for hook processes. Every hook lock used to
3
+ // carry its own inline mkdir/owner/backoff copy (auto-allow-bash, verification-guard,
4
+ // token-utils withFileLock, src/core/fileOps) with slightly divergent policies; this
5
+ // module is the shared protocol-compatible implementation for hook-side callers:
6
+ //
7
+ // - Acquisition is an atomic `mkdir` of `<target>.lock`; the owner is stamped into
8
+ // `owner` (pid + random token + ts) so stale reclaim can be validated instead of
9
+ // guessed. Same lock path and owner format as token-utils withFileLock, so hook
10
+ // processes and runtime/CLI processes serialize against each other.
11
+ // - Reclaim happens only after the stale threshold AND only when the recorded holder
12
+ // is provably gone (dead pid; a same-pid dir must also have no live in-process
13
+ // holder). A stale-LOOKING lock owned by a live process is never stolen.
14
+ // - Waiting polls with a jittered ASYNC backoff — never a blocking shared-memory
15
+ // wait, so the unref'd hook self-deadline timer keeps firing while a contended
16
+ // lock is being polled.
17
+ // - The acquisition budget is `deadlineMs`: callers derive it from the remaining
18
+ // hook deadline minus a cleanup reserve with lockBudgetMs (capped at a short
19
+ // slice), so a contended lock can never hold a hook near its own deadline.
20
+ // - Timeout/abort NEVER run the callback: the outcome is a typed envelope
21
+ // `{ ok: false, reason: 'busy' | 'aborted' }` and the caller picks its policy.
22
+ // Sensitive mutations (permission settings) stay fail-closed on lock failure;
23
+ // never write state "just unlocked" because a budget expired.
24
+ // - An acquisition only counts once the owner stamp is durably on disk. A failed
25
+ // stamp undoes the mkdir and fails closed (no callback) instead of running under an
26
+ // ownerless lock: a contender reads a missing owner as reclaimable after `staleMs`,
27
+ // so an unstamped live holder could have its lock deleted mid-critical-section.
28
+ import crypto from 'node:crypto';
29
+ import fs from 'node:fs/promises';
30
+ import path from 'node:path';
31
+
32
+ // Protocol-compatible with token-utils withFileLock.
33
+ export const LOCK_STALE_MS = 10_000;
34
+ // Reserve of the hook deadline kept free for release/cleanup I/O after the wait.
35
+ export const LOCK_RESERVE_MS = 500;
36
+ // Short configured acquisition slice: a contended lock gives up here, far inside a
37
+ // typical hook deadline, instead of riding the deadline out.
38
+ export const LOCK_MAX_SLICE_MS = 1_200;
39
+
40
+ const BACKOFF_MIN_MS = 3;
41
+ const BACKOFF_SPREAD_MS = 9;
42
+
43
+ /**
44
+ * Derive the lock acquisition budget from a hook's wall-clock deadline: the time
45
+ * remaining before that deadline, minus a reserve for release/cleanup, capped at the
46
+ * short configured slice (maxWaitMs). Always >= 0 — a budget smaller than the reserve
47
+ * budgets zero (single attempt, no waiting).
48
+ * @param {{ hookDeadlineMs?: number, startedAt?: number, reserveMs?: number, maxWaitMs?: number }} options
49
+ * @returns {number} budget in milliseconds
50
+ */
51
+ export function lockBudgetMs({
52
+ hookDeadlineMs,
53
+ startedAt = Date.now(),
54
+ reserveMs = LOCK_RESERVE_MS,
55
+ maxWaitMs = LOCK_MAX_SLICE_MS,
56
+ } = {}) {
57
+ const slice = Number.isFinite(maxWaitMs) && maxWaitMs >= 0 ? maxWaitMs : LOCK_MAX_SLICE_MS;
58
+ if (!Number.isFinite(hookDeadlineMs) || hookDeadlineMs < 0) {
59
+ return slice;
60
+ }
61
+ const elapsed = Math.max(0, Date.now() - startedAt);
62
+ const reserve = Number.isFinite(reserveMs) && reserveMs >= 0 ? reserveMs : LOCK_RESERVE_MS;
63
+ const remaining = Math.max(0, hookDeadlineMs - elapsed - reserve);
64
+ return Math.min(remaining, slice);
65
+ }
66
+
67
+ function lockBackoffDelayMs() {
68
+ return BACKOFF_MIN_MS + Math.floor(Math.random() * BACKOFF_SPREAD_MS);
69
+ }
70
+
71
+ // Resolves true when the sleep elapsed fully, false when the signal aborted first —
72
+ // an abort cancels the pending timer immediately instead of waiting out the slice.
73
+ function sleepWithAbort(ms, signal) {
74
+ if (signal?.aborted) return Promise.resolve(false);
75
+ return new Promise((resolve) => {
76
+ let timer = null;
77
+ const onAbort = () => {
78
+ if (timer) clearTimeout(timer);
79
+ resolve(false);
80
+ };
81
+ timer = setTimeout(() => {
82
+ if (signal) signal.removeEventListener('abort', onAbort);
83
+ resolve(true);
84
+ }, ms);
85
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
86
+ });
87
+ }
88
+
89
+ function isPidAlive(pid) {
90
+ try {
91
+ process.kill(pid, 0);
92
+ return true;
93
+ } catch (error) {
94
+ // EPERM: the process exists but belongs to another user — still alive.
95
+ return error?.code === 'EPERM';
96
+ }
97
+ }
98
+
99
+ async function readLockOwner(lockPath) {
100
+ try {
101
+ const raw = JSON.parse(await fs.readFile(path.join(lockPath, 'owner'), 'utf8'));
102
+ const pid = Number(raw?.pid);
103
+ return Number.isInteger(pid) && pid > 0
104
+ ? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
105
+ : null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ const RECLAIM_FILE = 'reclaim';
112
+
113
+ function sameLockOwner(left, right) {
114
+ if (!left || !right) return left === right;
115
+ return left.pid === right.pid && left.token === right.token;
116
+ }
117
+
118
+ async function readReclaimOwner(lockPath) {
119
+ try {
120
+ const raw = JSON.parse(await fs.readFile(path.join(lockPath, RECLAIM_FILE), 'utf8'));
121
+ const pid = Number(raw?.pid);
122
+ return Number.isInteger(pid) && pid > 0
123
+ ? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
124
+ : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ // Stale observers must claim the existing lock before removing it. The claim lives
131
+ // inside the old generation, so a second observer cannot remove that generation while
132
+ // the first observer is between its owner check and rm(). This closes the TOCTOU race
133
+ // where a delayed stale observer deleted a freshly acquired successor lock.
134
+ async function claimReclaim(lockPath, ownerToken, staleMs) {
135
+ const reclaimPath = path.join(lockPath, RECLAIM_FILE);
136
+ try {
137
+ const handle = await fs.open(reclaimPath, 'wx');
138
+ try {
139
+ await handle.writeFile(
140
+ `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
141
+ 'utf8',
142
+ );
143
+ } finally {
144
+ await handle.close();
145
+ }
146
+ return true;
147
+ } catch (error) {
148
+ if (error?.code !== 'EEXIST') return false;
149
+
150
+ // A killed reclaimer can leave its claim behind. Only clear an old claim whose
151
+ // recorded process is gone; a live claim remains the exclusive reclaim authority.
152
+ try {
153
+ const stat = await fs.stat(reclaimPath);
154
+ if (Date.now() - stat.mtimeMs > staleMs) {
155
+ const claim = await readReclaimOwner(lockPath);
156
+ if (!claim || !isPidAlive(claim.pid)) {
157
+ await fs.rm(reclaimPath, { force: true });
158
+ }
159
+ }
160
+ } catch {
161
+ // The lock or claim vanished; the caller's normal retry handles it.
162
+ }
163
+ return false;
164
+ }
165
+ }
166
+
167
+ async function releaseReclaimClaim(lockPath, ownerToken) {
168
+ try {
169
+ const claim = await readReclaimOwner(lockPath);
170
+ if (claim?.token === ownerToken) {
171
+ await fs.rm(path.join(lockPath, RECLAIM_FILE), { force: true });
172
+ }
173
+ } catch {
174
+ // best-effort claim release; the stale-claim path handles an interrupted cleanup
175
+ }
176
+ }
177
+
178
+ async function quarantineReclaim(lockPath, owner, ownerToken) {
179
+ const quarantinePath = `${lockPath}.reclaim-${ownerToken}`;
180
+ try {
181
+ // The claim serializes stale observers. Re-read before the atomic rename so an
182
+ // observer never detaches a generation different from the one it validated.
183
+ const current = await readLockOwner(lockPath);
184
+ if (!sameLockOwner(current, owner)) {
185
+ await releaseReclaimClaim(lockPath, ownerToken);
186
+ return false;
187
+ }
188
+ // Rename is atomic within the lock's parent directory: the old generation is
189
+ // detached as one filesystem operation, so a successor created at lockPath can
190
+ // never be reached by cleanup of this quarantined generation.
191
+ await fs.rename(lockPath, quarantinePath);
192
+ await fs.rm(quarantinePath, { recursive: true, force: true });
193
+ return true;
194
+ } catch {
195
+ // Never recursively remove lockPath here. If rename lost a race or failed, the
196
+ // original path belongs to whoever currently holds it; a later poll can retry.
197
+ try {
198
+ await fs.rm(quarantinePath, { recursive: true, force: true });
199
+ } catch {
200
+ // best-effort cleanup of only this acquisition's quarantine path
201
+ }
202
+ return false;
203
+ }
204
+ }
205
+
206
+ // In-process holder registry: same-pid holders are parallel async flows whose liveness
207
+ // a pid probe cannot prove, so the module tracks them itself (same as withFileLock).
208
+ const inProcessLockHolders = new Map();
209
+
210
+ /**
211
+ * Serialize a mutation of a shared state file across hook processes and concurrent
212
+ * async flows. Protocol-compatible with token-utils withFileLock (`<file>.lock`
213
+ * directory + `owner` file). Unlike withFileLock this NEVER runs the callback
214
+ * unlocked: an expired budget or an aborted signal resolves with a typed outcome and
215
+ * the caller decides the fail-safe policy.
216
+ * @param {string} filePath - state file the mutation targets (lock lives beside it)
217
+ * @param {{
218
+ * signal?: AbortSignal,
219
+ * deadlineMs?: number,
220
+ * staleMs?: number,
221
+ * }} [options]
222
+ * @param {() => Promise<*>} fn - critical section
223
+ * @returns {Promise<{ ok: true, value: * } | { ok: false, reason: 'busy' | 'aborted', waitedMs: number }>}
224
+ * fn result wrapped on success, or a typed busy/abort outcome (fn never ran)
225
+ */
226
+ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SLICE_MS, staleMs = LOCK_STALE_MS } = {}, fn) {
227
+ if (typeof fn !== 'function') {
228
+ throw new TypeError('withAsyncLock(filePath, options, fn) requires a callback');
229
+ }
230
+ if (signal?.aborted) {
231
+ return { ok: false, reason: 'aborted', waitedMs: 0 };
232
+ }
233
+
234
+ const lockPath = `${filePath}.lock`;
235
+ const budget = Number.isFinite(deadlineMs) && deadlineMs >= 0 ? deadlineMs : LOCK_MAX_SLICE_MS;
236
+ const stale = Number.isFinite(staleMs) && staleMs >= 0 ? staleMs : LOCK_STALE_MS;
237
+ const startedAt = Date.now();
238
+ const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
239
+ // True only when THIS acquisition holds a lock it can prove it owns: the atomic
240
+ // mkdir succeeded AND the owner stamp is on disk. Release is verified against it.
241
+ let owned = false;
242
+
243
+ while (true) {
244
+ try {
245
+ // The lock parent must exist before the atomic acquire — a first-ever run in a
246
+ // fresh project would otherwise fail mkdir with ENOENT.
247
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
248
+ await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
249
+ inProcessLockHolders.set(lockPath, ownerToken);
250
+ try {
251
+ await fs.writeFile(
252
+ path.join(lockPath, 'owner'),
253
+ `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
254
+ 'utf8',
255
+ );
256
+ } catch {
257
+ // Fail closed: an unstamped lock is not ours to enter. Running the callback
258
+ // anyway would leave an ownerless directory, and every other process reads a
259
+ // missing owner as reclaimable once `staleMs` passes — deleting a LIVE holder's
260
+ // lock mid-critical-section and letting two mutations interleave. Undo the
261
+ // acquire and report the typed busy outcome callers already handle.
262
+ try {
263
+ const current = await readLockOwner(lockPath);
264
+ // Never remove a directory some other holder has since stamped (only
265
+ // possible if this one was reclaimed in the window above).
266
+ if (!current || current.token === ownerToken) {
267
+ await fs.rm(lockPath, { recursive: true, force: true });
268
+ }
269
+ } catch {
270
+ // best-effort undo; a leftover dir is unheld and reclaimed by the next waiter
271
+ }
272
+ if (inProcessLockHolders.get(lockPath) === ownerToken) inProcessLockHolders.delete(lockPath);
273
+ return { ok: false, reason: 'busy', waitedMs: Date.now() - startedAt };
274
+ }
275
+ owned = true;
276
+ break;
277
+ } catch (error) {
278
+ if (error?.code !== 'EEXIST') throw error;
279
+ }
280
+
281
+ // Someone holds the lock. Reclaim it only after the stale threshold AND only when
282
+ // the holder is provably gone — stealing a live holder reintroduces the exact
283
+ // interleaved-write race this lock exists to prevent.
284
+ let reclaimed = false;
285
+ try {
286
+ const stat = await fs.stat(lockPath);
287
+ if (Date.now() - stat.mtimeMs > stale) {
288
+ const owner = await readLockOwner(lockPath);
289
+ const liveInProcess = inProcessLockHolders.has(lockPath);
290
+ const reclaimable = !owner || owner.pid === process.pid
291
+ ? !liveInProcess
292
+ : !isPidAlive(owner.pid);
293
+ if (reclaimable && await claimReclaim(lockPath, ownerToken, stale)) {
294
+ // Detach and clean only the generation that was validated. The atomic rename
295
+ // makes this safe even when another process acquires lockPath immediately
296
+ // after the stale generation is removed.
297
+ reclaimed = await quarantineReclaim(lockPath, owner, ownerToken);
298
+ }
299
+ }
300
+ } catch {
301
+ reclaimed = false; // lock vanished between mkdir and stat — plain retry handles it
302
+ }
303
+ if (reclaimed) continue;
304
+
305
+ const waitedMs = Date.now() - startedAt;
306
+ if (waitedMs >= budget) {
307
+ // Fail closed: the caller's policy decides what a busy lock means. The callback
308
+ // is NOT invoked unlocked.
309
+ return { ok: false, reason: 'busy', waitedMs };
310
+ }
311
+
312
+ // Jittered async backoff, clamped so a sleep never overshoots the budget.
313
+ const remaining = budget - (Date.now() - startedAt);
314
+ const sleptFully = await sleepWithAbort(Math.min(lockBackoffDelayMs(), Math.max(1, remaining)), signal);
315
+ if (!sleptFully) {
316
+ return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
317
+ }
318
+ }
319
+
320
+ try {
321
+ return { ok: true, value: await fn() };
322
+ } finally {
323
+ // `owned` is only true when the mkdir AND the owner stamp both succeeded, so a
324
+ // failed stamp never reaches the critical section and never needs releasing here.
325
+ if (owned) {
326
+ try {
327
+ // Remove the lock only if THIS acquisition still owns it: after a stale
328
+ // reclaim another holder may already own the dir, and deleting it would
329
+ // unlock their critical section for a third waiter.
330
+ const current = await readLockOwner(lockPath);
331
+ if (current && current.token === ownerToken) {
332
+ await fs.rm(lockPath, { recursive: true, force: true });
333
+ }
334
+ } catch {
335
+ // best-effort release; a leaked dir is reclaimed by the next waiter
336
+ }
337
+ if (inProcessLockHolders.get(lockPath) === ownerToken) inProcessLockHolders.delete(lockPath);
338
+ }
339
+ }
340
+ }
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { pathToFileURL } from 'node:url';
4
5
  import {
@@ -11,6 +12,9 @@ import {
11
12
  withFileLock,
12
13
  writeJson,
13
14
  } from './token-utils.mjs';
15
+ // Sibling runtime module: installs copy the whole runtime dir, so the negotiated-capacity
16
+ // resolver is always present next to this file.
17
+ import { resolveContextCapTokens } from './context-capacity.mjs';
14
18
 
15
19
  const DEFAULT_MAX_PROMPT_ENTRIES = 12;
16
20
  const DEFAULT_MAX_OUTPUT_ENTRIES = 12;
@@ -444,30 +448,64 @@ function computeEstimatedTotalTokens({
444
448
  return baselineTokens + estimatedContextTokens + windowTokens + sessionExcess;
445
449
  }
446
450
 
451
+ // Live capacity evidence published by context-window-guard.sh — the only hook that sees the
452
+ // real serving model. This record is how negotiated capacity reaches the shared
453
+ // threshold/gate path; without it a verified 200k route would still compact at 240k.
454
+ export const CAPACITY_RECORD_SEGMENTS = ['.ukit', 'storage', 'cache', 'context-capacity.json'];
455
+ // Shipped tuning as a ratio of the absolute cap (150_000 soft of a 500_000 cap), so a
456
+ // smaller verified window moves the advisory phases down with the cap they precede.
457
+ export const SOFT_TO_CAP_RATIO = 0.3;
458
+
459
+ export function readContextCapacityRecord(projectRoot) {
460
+ const root = projectRoot
461
+ || process.env.CLAUDE_PROJECT_DIR
462
+ || process.env.PROJECT_ROOT
463
+ || process.cwd();
464
+ try {
465
+ const parsed = JSON.parse(fsSync.readFileSync(path.join(root, ...CAPACITY_RECORD_SEGMENTS), 'utf8'));
466
+ return parsed && typeof parsed === 'object' ? parsed : null;
467
+ } catch {
468
+ return null;
469
+ }
470
+ }
471
+
472
+ // Negotiated capacity: env override > config override > the model the guard last observed >
473
+ // conservative fallback. Exported so the source twin and diagnostics read the same number.
474
+ export function resolveCompactCapacity(config = {}) {
475
+ const record = readContextCapacityRecord(config?.projectRoot);
476
+ return resolveContextCapTokens({
477
+ env: process.env,
478
+ config,
479
+ modelMetadata: { model: record?.model },
480
+ });
481
+ }
482
+
447
483
  export function buildCompactThresholds(config = {}) {
448
- const softThreshold = Math.max(1, finiteNumber(config?.compact?.tokenThreshold, 150_000));
484
+ const explicitSoftThreshold = finiteNumber(config?.compact?.tokenThreshold, 0);
485
+ const shippedHardCap = positiveInteger(config?.compact?.hardCapTokens, 500_000);
486
+ // Only tighten once a record exists: an install with no evidence yet (and every caller
487
+ // that passes no project root) keeps the shipped tuning unchanged.
488
+ const record = readContextCapacityRecord(config?.projectRoot);
489
+ const negotiated = record ? resolveCompactCapacity(config) : null;
490
+ const hardCapTokens = negotiated
491
+ ? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
492
+ : shippedHardCap;
493
+ // An explicit operator tokenThreshold is honored as-is; otherwise the advisory phase is
494
+ // derived from the cap it precedes, so a soft phase can never sit above the cap (150k of
495
+ // advisory over a negotiated 100k cap would simply never fire).
496
+ const softThreshold = explicitSoftThreshold > 0
497
+ ? Math.max(1, explicitSoftThreshold)
498
+ : Math.max(1, Math.round(hardCapTokens * SOFT_TO_CAP_RATIO));
449
499
  const hardThreshold = Math.max(softThreshold + 1, Math.round(softThreshold * 1.6));
450
500
  const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
451
- // Deliberately NOT coupled to hardThreshold: this is an absolute ceiling, so raising
452
- // the advisory tokenThreshold must never silently raise the cap along with it.
453
- //
454
- // Must stay BELOW the model's real context window or the cap is unreachable: the API
455
- // rejects the request with "input exceeds the context window" long before an estimate
456
- // climbing toward a higher number ever trips this. 500_000 is half of a 1M window, which
457
- // leaves ample headroom for the response plus the estimator's own undercount. On any
458
- // smaller model this default sits ABOVE the window, so the gate can never fire and is
459
- // dead code in exactly the situation it exists to prevent — set compact.hardCapTokens to
460
- // 100_000 (200k model) or 128_000 (256k model) for those. Both
461
- // env.CLAUDE_CODE_AUTO_COMPACT_WINDOW and omp's compaction.thresholdTokens are rendered
462
- // from this number at install time (70% of it), so the client auto-compacts before the
463
- // gate blocks tools without anyone hand-syncing a second value.
464
- const hardCapTokens = positiveInteger(config?.compact?.hardCapTokens, 500_000);
465
501
 
466
502
  return {
467
503
  softThreshold,
468
504
  hardThreshold,
469
505
  baselineTokens,
470
506
  hardCapTokens,
507
+ capacitySource: negotiated?.capacity?.source ?? 'shipped',
508
+ capacityTokens: negotiated?.capacity?.tokens ?? null,
471
509
  };
472
510
  }
473
511
 
@@ -1175,14 +1213,21 @@ export async function readCompactPressureState(projectRoot, config = {}) {
1175
1213
  // their read-modify-write cycles and silently drop each other's sessionTokens.
1176
1214
  // Mutations are session-scoped: only the calling session's record is rewritten;
1177
1215
  // sibling session records pass through untouched.
1216
+ // Every async entry point already knows the project root; carry it into the config so the
1217
+ // negotiated-capacity lookup reads THIS project's record rather than guessing from cwd.
1218
+ function scopeCapacityConfig(config, projectRoot) {
1219
+ return projectRoot ? { ...config, projectRoot } : config;
1220
+ }
1221
+
1178
1222
  async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
1179
1223
  const runtimePaths = buildRuntimePaths(projectRoot);
1224
+ const scopedConfig = scopeCapacityConfig(config, projectRoot);
1180
1225
  return withFileLock(runtimePaths.compactPressurePath, async () => {
1181
1226
  const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
1182
1227
  const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1183
- const current = buildCompactPressureState(record, config);
1228
+ const current = buildCompactPressureState(record, scopedConfig);
1184
1229
  const next = mutator(current);
1185
- const normalized = buildCompactPressureState(next, config);
1230
+ const normalized = buildCompactPressureState(next, scopedConfig);
1186
1231
  sessions[id] = { ...normalized, updatedAt: Date.now() };
1187
1232
  await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
1188
1233
  return normalized;
@@ -1191,10 +1236,11 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
1191
1236
 
1192
1237
  export async function writeCompactPressureState(projectRoot, state, config = {}) {
1193
1238
  const runtimePaths = buildRuntimePaths(projectRoot);
1239
+ const scopedConfig = scopeCapacityConfig(config, projectRoot);
1194
1240
  return withFileLock(runtimePaths.compactPressurePath, async () => {
1195
1241
  const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
1196
1242
  const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1197
- const normalized = buildCompactPressureState(state, config);
1243
+ const normalized = buildCompactPressureState(state, scopedConfig);
1198
1244
  sessions[id] = { ...normalized, updatedAt: Date.now() };
1199
1245
  await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
1200
1246
  return normalized;
@@ -1209,24 +1255,24 @@ function pressureSessionConfig(payload = {}, config = {}) {
1209
1255
  export async function updateCompactPressureFromPrompt(projectRoot, payload, config = {}) {
1210
1256
  return mutateCompactPressureState(
1211
1257
  projectRoot,
1212
- (current) => registerPromptPressure(current, payload, config),
1213
- pressureSessionConfig(payload, config),
1258
+ (current) => registerPromptPressure(current, payload, scopeCapacityConfig(config, projectRoot)),
1259
+ pressureSessionConfig(payload, scopeCapacityConfig(config, projectRoot)),
1214
1260
  );
1215
1261
  }
1216
1262
 
1217
1263
  export async function updateCompactPressureFromOutput(projectRoot, payload, config = {}) {
1218
1264
  return mutateCompactPressureState(
1219
1265
  projectRoot,
1220
- (current) => registerOutputPressure(current, payload, config),
1221
- pressureSessionConfig(payload, config),
1266
+ (current) => registerOutputPressure(current, payload, scopeCapacityConfig(config, projectRoot)),
1267
+ pressureSessionConfig(payload, scopeCapacityConfig(config, projectRoot)),
1222
1268
  );
1223
1269
  }
1224
1270
 
1225
1271
  export async function writeThresholdCompactPlan(projectRoot, plan, config = {}) {
1226
1272
  return mutateCompactPressureState(
1227
1273
  projectRoot,
1228
- (current) => registerThresholdCompactPlan(current, plan, config),
1229
- pressureSessionConfig(plan, config),
1274
+ (current) => registerThresholdCompactPlan(current, plan, scopeCapacityConfig(config, projectRoot)),
1275
+ pressureSessionConfig(plan, scopeCapacityConfig(config, projectRoot)),
1230
1276
  );
1231
1277
  }
1232
1278
 
@@ -1288,6 +1334,9 @@ async function runCli() {
1288
1334
  // this session's pressure history.
1289
1335
  const sessionConfig = {
1290
1336
  ...config,
1337
+ // Thread the resolved project root so the negotiated-capacity lookup reads THIS
1338
+ // project's record instead of guessing from cwd.
1339
+ projectRoot,
1291
1340
  sessionId: typeof payload.session_id === 'string' && payload.session_id.trim()
1292
1341
  ? payload.session_id.trim()
1293
1342
  : undefined,
@@ -0,0 +1,144 @@
1
+ // Negotiated context capacity (H22) — UKit must never assume one fixed model window.
2
+ //
3
+ // The advisory context guard and compact thresholds used to hard-code a 500k cap. That
4
+ // number was tuned for a 1M-class window: on a smaller model the cap sat ABOVE the real
5
+ // window, so the gate was dead code in exactly the situation it exists to prevent. This
6
+ // module instead NEGOTIATES the capacity from the best evidence available, in a fixed,
7
+ // documented precedence, and reports where the number came from so diagnostics can show
8
+ // it:
9
+ //
10
+ // 1. env.UKIT_CONTEXT_WINDOW_TOKENS — operator override for this session/route.
11
+ // 2. config.compact.contextWindowTokens — operator override in .ukit/storage/config.json.
12
+ // 3. verified model metadata — exact-prefix lookup in the verified window table.
13
+ // 4. conservative default — smallest verified window, source `default`.
14
+ //
15
+ // Rules this module enforces:
16
+ // - Never infer capacity from a provider name alone. A model id must match a verified
17
+ // table entry by prefix (so dated suffixes like `-20250929` still match); anything
18
+ // else — including an explicit `verified: false` — uses the conservative fallback.
19
+ // - Malformed values skip ONE source and fall through to the next; they never poison
20
+ // the resolution and never throw.
21
+ // - Verified capacities are clamped to [MIN_CAPACITY_TOKENS, MAX_CAPACITY_TOKENS] so a
22
+ // typo cannot produce a zero-token session or an unbounded one.
23
+
24
+ export const CAPACITY_ENV_VAR = 'UKIT_CONTEXT_WINDOW_TOKENS';
25
+ // Unverified fallback CAP: what the guard assumes when nothing is known about the route.
26
+ // Deliberately BELOW every verified window (smallest is gpt-4o, 128k), so an unknown route
27
+ // is warned before its provider limit, not after — the old 500k sat above a 128k/200k
28
+ // window and gave such a route no warning at all until the API rejected the request. A
29
+ // known bigger-but-unlisted route is raised via UKIT_CONTEXT_WINDOW_TOKENS.
30
+ export const DEFAULT_CAPACITY_TOKENS = 100_000;
31
+ // The shipped operator ceiling (compact.hardCapTokens) — only ever an UPPER bound; negotiated
32
+ // capacity can tighten it but never raise it.
33
+ export const SHIPPED_HARD_CAP_TOKENS = 500_000;
34
+ // A verified window is only ever usable as an estimate: the response itself plus the
35
+ // char/token estimator's own undercount must fit too, so only half the window becomes
36
+ // the advisory cap (500k cap from a 1M window — the original tuning, made explicit).
37
+ export const CAPACITY_TO_CAP_RATIO = 0.5;
38
+ export const MIN_CAPACITY_TOKENS = 1_000;
39
+ export const MAX_CAPACITY_TOKENS = 2_000_000;
40
+
41
+ // Verified model context windows. Longest-prefix match on the lowercased model id; a
42
+ // bare provider prefix ("gpt", "claude", "gemini") is deliberately NOT a table entry.
43
+ const VERIFIED_MODEL_CONTEXT_WINDOWS = [
44
+ ['claude-opus-4-1', 200_000],
45
+ ['claude-opus-4', 200_000],
46
+ ['claude-sonnet-4-5', 200_000],
47
+ ['claude-sonnet-4', 200_000],
48
+ ['claude-haiku-4-5', 200_000],
49
+ ['claude-haiku-4', 200_000],
50
+ ['claude-3-7-sonnet', 200_000],
51
+ ['claude-3-5-sonnet', 200_000],
52
+ ['claude-3-5-haiku', 200_000],
53
+ ['claude-3-opus', 200_000],
54
+ ['claude-3-haiku', 200_000],
55
+ ['gpt-4.1', 1_000_000],
56
+ ['gpt-4o', 128_000],
57
+ ['o4-mini', 200_000],
58
+ ['o3', 200_000],
59
+ ['gemini-2.5-pro', 1_000_000],
60
+ ['gemini-2.5-flash', 1_000_000],
61
+ ];
62
+
63
+ // Env vars are always strings: only strict digit strings count as evidence.
64
+ // Anything else (floats, scientific notation, signed/garbage strings) returns null so
65
+ // the resolver falls through to the next source.
66
+ function parseEnvTokenCount(value) {
67
+ if (typeof value !== 'string') return null;
68
+ const trimmed = value.trim();
69
+ if (!/^\d+$/.test(trimmed)) return null;
70
+ const parsed = Number(trimmed);
71
+ return parsed > 0 ? parsed : null;
72
+ }
73
+
74
+ // Config values are typed JSON: mirror compact-threshold.mjs's number-only contract so a
75
+ // string that happens to look numeric can never silently become a real ceiling.
76
+ function parseConfigTokenCount(value) {
77
+ return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0
78
+ ? value
79
+ : null;
80
+ }
81
+
82
+ function clampCapacity(tokens) {
83
+ return Math.min(MAX_CAPACITY_TOKENS, Math.max(MIN_CAPACITY_TOKENS, tokens));
84
+ }
85
+
86
+ function lookupVerifiedModelWindow(model) {
87
+ if (typeof model !== 'string') return null;
88
+ const normalized = model.trim().toLowerCase();
89
+ if (!normalized) return null;
90
+ let best = null;
91
+ for (const entry of VERIFIED_MODEL_CONTEXT_WINDOWS) {
92
+ const [prefix, windowTokens] = entry;
93
+ if (normalized.startsWith(prefix) && (!best || prefix.length > best[0].length)) {
94
+ best = entry;
95
+ }
96
+ }
97
+ return best ? best[1] : null;
98
+ }
99
+
100
+ // Resolve the model context capacity from the best available evidence.
101
+ // Returns `{tokens, source, confidence}`:
102
+ // source — 'env' | 'config' | 'model' | 'default'
103
+ // confidence — 'verified' for the first three, 'fallback' for the default.
104
+ export function resolveContextCapacity({ env = {}, config = {}, modelMetadata = {} } = {}) {
105
+ const fromEnv = parseEnvTokenCount(env?.[CAPACITY_ENV_VAR]);
106
+ if (fromEnv !== null) {
107
+ return { tokens: clampCapacity(fromEnv), source: 'env', confidence: 'verified' };
108
+ }
109
+ const fromConfig = parseConfigTokenCount(config?.compact?.contextWindowTokens);
110
+ if (fromConfig !== null) {
111
+ return { tokens: clampCapacity(fromConfig), source: 'config', confidence: 'verified' };
112
+ }
113
+ // An explicit `verified: false` on the route metadata means the caller already knows
114
+ // this route is unverified — do not even try the table.
115
+ if (modelMetadata?.verified === false) {
116
+ return { tokens: DEFAULT_CAPACITY_TOKENS, source: 'default', confidence: 'fallback' };
117
+ }
118
+ const fromModel = lookupVerifiedModelWindow(modelMetadata?.model);
119
+ if (fromModel !== null) {
120
+ return { tokens: fromModel, source: 'model', confidence: 'verified' };
121
+ }
122
+ return { tokens: DEFAULT_CAPACITY_TOKENS, source: 'default', confidence: 'fallback' };
123
+ }
124
+
125
+ // Compose the advisory cap for the context guard from the negotiated capacity plus the
126
+ // operator's absolute ceiling (compact.hardCapTokens). The derived cap never exceeds the
127
+ // operator ceiling and never lands at zero.
128
+ //
129
+ // An explicit compact.hardCapTokens IS a capacity declaration (an operator who wrote
130
+ // 120_000 for their 200k model knows their window), so the conservative fallback only
131
+ // applies when NOTHING is declared — no env, no contextWindowTokens, no hardCapTokens and
132
+ // no verified model. Anything verified is halved (CAPACITY_TO_CAP_RATIO).
133
+ export function resolveContextCapTokens({ env, config, modelMetadata } = {}) {
134
+ const capacity = resolveContextCapacity({ env, config, modelMetadata });
135
+ const operatorHardCap = parseConfigTokenCount(config?.compact?.hardCapTokens);
136
+ const ceiling = operatorHardCap ?? SHIPPED_HARD_CAP_TOKENS;
137
+ const derived = capacity.source === 'default'
138
+ ? (operatorHardCap ?? capacity.tokens)
139
+ : Math.round(capacity.tokens * CAPACITY_TO_CAP_RATIO);
140
+ return {
141
+ capacity,
142
+ capTokens: Math.max(1, Math.min(ceiling, derived)),
143
+ };
144
+ }