@ngockhoale/ukit 2.4.1 → 2.4.2
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 +65 -0
- package/package.json +1 -1
- package/scripts/index/refresh-index.mjs +10 -5
- package/src/cli/commands/doctor.js +59 -2
- package/src/core/gatewayProbe.js +143 -15
- package/src/core/gatewayResilienceEnv.js +136 -7
- package/src/index/buildIndex.js +74 -24
- package/templates/.claude/hooks/auto-allow-bash.sh +24 -1
- package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
- package/templates/.claude/hooks/block-dangerous.sh +20 -1
- package/templates/.claude/hooks/completion-gate.sh +19 -2
- package/templates/.claude/hooks/compress-output.sh +17 -2
- package/templates/.claude/hooks/context-hardcap-gate.sh +22 -3
- package/templates/.claude/hooks/context-window-guard.sh +84 -61
- package/templates/.claude/hooks/handoff-model-guard.sh +24 -3
- package/templates/.claude/hooks/handoff-resume.sh +21 -3
- package/templates/.claude/hooks/post-edit-verify.sh +19 -2
- package/templates/.claude/hooks/pre-edit-backup.sh +19 -2
- package/templates/.claude/hooks/protect-files.sh +20 -1
- package/templates/.claude/hooks/record-execution.sh +20 -2
- package/templates/.claude/hooks/reinject-context.sh +1 -1
- package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
- package/templates/.claude/hooks/sensitive-data-guard.sh +64 -17
- package/templates/.claude/hooks/skill-router.sh +33 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +21 -2
- package/templates/.claude/hooks/task-watchdog.sh +25 -7
- package/templates/.claude/hooks/verification-guard.sh +54 -19
- package/templates/.claude/hooks/vision-router.sh +96 -12
- package/templates/.claude/ukit/index/lib/index-core.mjs +78 -16
- package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
- package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
- package/templates/.claude/ukit/index/refresh-index.mjs +10 -5
- package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +8 -0
- package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
- package/templates/.claude/ukit/runtime/hook-input.sh +60 -0
- package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
- package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
|
@@ -4,14 +4,24 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
|
|
6
6
|
// Managed defaults that keep Claude Code responsive when it is pointed at a custom
|
|
7
|
-
// gateway (any non-empty ANTHROPIC_BASE_URL).
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// gateway (any non-empty ANTHROPIC_BASE_URL). Posture since 2.4.2 — "fail fast, recover
|
|
8
|
+
// via non-streaming" — based on 2026-09-16 live probes of the stall-class gateway:
|
|
9
|
+
// * The gateway buffers entire streams (23 SSE events delivered in ONE body read), so a
|
|
10
|
+
// long generation is silent until it finishes and Claude Code's stream-idle watchdog
|
|
11
|
+
// is the only thing that can end the silence. `CLAUDE_STREAM_IDLE_TIMEOUT_MS='120000'`
|
|
12
|
+
// bounds that dead air to 2 minutes instead of hanging for 10.
|
|
13
|
+
// * The non-streaming route is HEALTHY on the stall-class gateway (HTTP 200 + valid
|
|
14
|
+
// Anthropic Message), and a non-streaming POST has no idle watchdog at all — it is the
|
|
15
|
+
// one request shape that reliably survives a buffered long generation. So the fallback
|
|
16
|
+
// must stay ENABLED (`CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK='0'`); disabling it
|
|
17
|
+
// (the 2.3.16–2.4.1 posture) removed the only recovery path and turned stalls into
|
|
18
|
+
// silent freezes the user had to interrupt by hand.
|
|
12
19
|
//
|
|
13
20
|
// These are written ONLY when no user-set value already lives in the project's
|
|
14
|
-
// `.claude/settings.json` env block — never clobber, always preserve
|
|
21
|
+
// `.claude/settings.json` env block — never clobber, always preserve — EXCEPT for values
|
|
22
|
+
// equal to `GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS` (the shipped defaults of
|
|
23
|
+
// 2.3.16–2.4.1): those were written by UKit itself, so they migrate to the current
|
|
24
|
+
// defaults on the next install instead of being mistaken for user intent. Re-running
|
|
15
25
|
// `ukit install` is a no-op once the keys are in place.
|
|
16
26
|
//
|
|
17
27
|
// Probe order mirrors `templates/.claude/ukit/index/unic-gateway.mjs` (env → project
|
|
@@ -21,12 +31,22 @@ import path from 'node:path';
|
|
|
21
31
|
// is pointed at ANY gateway, so we apply resilience regardless of vendor.
|
|
22
32
|
|
|
23
33
|
export const GATEWAY_RESILIENCE_ENV_DEFAULTS = Object.freeze({
|
|
34
|
+
CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: '0',
|
|
35
|
+
CLAUDE_STREAM_IDLE_TIMEOUT_MS: '120000',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Values shipped as the managed defaults by 2.3.16–2.4.1. A settings env key holding one
|
|
39
|
+
// of these exact values was written by UKit, not by the user, so `applyGatewayResilienceEnv`
|
|
40
|
+
// migrates it to the current default. A user who deliberately tuned a value always uses a
|
|
41
|
+
// non-shipped number (e.g. '300000', '900000') and stays untouched.
|
|
42
|
+
export const GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS = Object.freeze({
|
|
24
43
|
CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: '1',
|
|
25
44
|
CLAUDE_STREAM_IDLE_TIMEOUT_MS: '600000',
|
|
26
45
|
});
|
|
27
46
|
|
|
28
47
|
const BASE_URL_ENV_VAR = 'ANTHROPIC_BASE_URL';
|
|
29
48
|
const SETTINGS_RELATIVE_PATH = path.join('.claude', 'settings.json');
|
|
49
|
+
const PROFILE_RELATIVE_PATH = path.join('.claude', 'profile');
|
|
30
50
|
|
|
31
51
|
function readBaseUrlFromEnvValue(value) {
|
|
32
52
|
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
|
@@ -124,6 +144,7 @@ function backupPathFor(settingsPath) {
|
|
|
124
144
|
* applied: string[],
|
|
125
145
|
* unchanged: string[],
|
|
126
146
|
* skipped: string[],
|
|
147
|
+
* migrated: string[],
|
|
127
148
|
* changed: boolean,
|
|
128
149
|
* reason?: string,
|
|
129
150
|
* }>}
|
|
@@ -162,6 +183,7 @@ export async function applyGatewayResilienceEnv({
|
|
|
162
183
|
applied: [],
|
|
163
184
|
unchanged: [],
|
|
164
185
|
skipped: [],
|
|
186
|
+
migrated: [],
|
|
165
187
|
changed: false,
|
|
166
188
|
reason: `failed to read settings: ${error?.message ?? String(error)}`,
|
|
167
189
|
};
|
|
@@ -180,6 +202,7 @@ export async function applyGatewayResilienceEnv({
|
|
|
180
202
|
applied: [],
|
|
181
203
|
unchanged: [],
|
|
182
204
|
skipped: [],
|
|
205
|
+
migrated: [],
|
|
183
206
|
changed: false,
|
|
184
207
|
reason: `failed to parse settings JSON: ${error?.message ?? String(error)}`,
|
|
185
208
|
};
|
|
@@ -204,6 +227,7 @@ export async function applyGatewayResilienceEnv({
|
|
|
204
227
|
const applied = [];
|
|
205
228
|
const unchanged = [];
|
|
206
229
|
const skipped = [];
|
|
230
|
+
const migrated = [];
|
|
207
231
|
let mutated = false;
|
|
208
232
|
|
|
209
233
|
for (const [key, defaultValue] of Object.entries(GATEWAY_RESILIENCE_ENV_DEFAULTS)) {
|
|
@@ -218,6 +242,18 @@ export async function applyGatewayResilienceEnv({
|
|
|
218
242
|
unchanged.push(key);
|
|
219
243
|
continue;
|
|
220
244
|
}
|
|
245
|
+
// A value equal to a previously-shipped UKit default was written by UKit itself —
|
|
246
|
+
// migrate it to the current default instead of treating it as user intent.
|
|
247
|
+
if (
|
|
248
|
+
typeof currentValue === 'string' &&
|
|
249
|
+
currentValue === GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS[key]
|
|
250
|
+
) {
|
|
251
|
+
settings.env[key] = defaultValue;
|
|
252
|
+
applied.push(key);
|
|
253
|
+
migrated.push(key);
|
|
254
|
+
mutated = true;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
221
257
|
// User owns a non-default value — never touch it.
|
|
222
258
|
skipped.push(key);
|
|
223
259
|
}
|
|
@@ -228,6 +264,7 @@ export async function applyGatewayResilienceEnv({
|
|
|
228
264
|
applied,
|
|
229
265
|
unchanged,
|
|
230
266
|
skipped,
|
|
267
|
+
migrated,
|
|
231
268
|
changed: false,
|
|
232
269
|
};
|
|
233
270
|
}
|
|
@@ -243,6 +280,7 @@ export async function applyGatewayResilienceEnv({
|
|
|
243
280
|
applied,
|
|
244
281
|
unchanged,
|
|
245
282
|
skipped,
|
|
283
|
+
migrated,
|
|
246
284
|
changed: true,
|
|
247
285
|
};
|
|
248
286
|
}
|
|
@@ -271,7 +309,11 @@ export function formatGatewayResilienceReport(report) {
|
|
|
271
309
|
);
|
|
272
310
|
|
|
273
311
|
for (const key of report.applied || []) {
|
|
274
|
-
|
|
312
|
+
const legacyValue = GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS[key];
|
|
313
|
+
const migrated = (report.migrated || []).includes(key) && legacyValue !== undefined;
|
|
314
|
+
lines.push(
|
|
315
|
+
` - ${key} = ${JSON.stringify(GATEWAY_RESILIENCE_ENV_DEFAULTS[key])}${migrated ? ` (migrated from previous UKit default ${JSON.stringify(legacyValue)})` : ' (applied)'}`,
|
|
316
|
+
);
|
|
275
317
|
}
|
|
276
318
|
for (const key of report.unchanged || []) {
|
|
277
319
|
lines.push(` - ${key} = ${JSON.stringify(GATEWAY_RESILIENCE_ENV_DEFAULTS[key])} (already set)`);
|
|
@@ -290,3 +332,90 @@ export function formatGatewayResilienceReport(report) {
|
|
|
290
332
|
|
|
291
333
|
return lines;
|
|
292
334
|
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Advisory presence scan of the per-profile settings files under
|
|
338
|
+
* `<homeDir>/.claude/profile/`. A session launched with
|
|
339
|
+
* `--settings ~/.claude/profile/<name>.json` bypasses `~/.claude/settings.json` entirely, so
|
|
340
|
+
* a managed resilience key can be absent exactly where it matters while every other check
|
|
341
|
+
* reports green. This is the delivery-gap detector for that hole.
|
|
342
|
+
*
|
|
343
|
+
* Presence-check only — never a value-check. After the documented posture flip the values
|
|
344
|
+
* are user/machine-owned (a profile may legitimately hold a legacy `'1'`/`'600000'` pair or a
|
|
345
|
+
* tuned number), so "is the key present" is the whole contract and no value is compared
|
|
346
|
+
* against `GATEWAY_RESILIENCE_ENV_DEFAULTS`.
|
|
347
|
+
*
|
|
348
|
+
* Only regular files ending in `.json` are scanned (sorted by name); decoys that live in the
|
|
349
|
+
* real profile dir — backups like `x.json.bak-20260916`, `notes.sh`, and subdirectories — are
|
|
350
|
+
* ignored, and the scan is not recursive. An absent profile directory is not an error: it
|
|
351
|
+
* resolves `{ scanned: [], compliant: true }` so a machine that never used profiles stays
|
|
352
|
+
* quiet. Never throws: an unreadable directory, unreadable file, or invalid JSON is
|
|
353
|
+
* reflected in the per-file result instead of propagating.
|
|
354
|
+
*
|
|
355
|
+
* @param {{ homeDir?: string, profileDir?: string|null, keys?: string[]|null }} [options]
|
|
356
|
+
* @returns {Promise<{ profileDir: string, scanned: Array<{ file: string, validJson: boolean, hasEnvBlock: boolean, missing: string[] }>, compliant: boolean }>}
|
|
357
|
+
*/
|
|
358
|
+
export async function scanProfileResilienceEnv({
|
|
359
|
+
homeDir = os.homedir(),
|
|
360
|
+
profileDir = null,
|
|
361
|
+
keys = null,
|
|
362
|
+
} = {}) {
|
|
363
|
+
const resolvedProfileDir = profileDir || path.join(homeDir, PROFILE_RELATIVE_PATH);
|
|
364
|
+
const managedKeys = Array.isArray(keys) && keys.length > 0
|
|
365
|
+
? keys
|
|
366
|
+
: Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS);
|
|
367
|
+
|
|
368
|
+
const emptyResult = { profileDir: resolvedProfileDir, scanned: [], compliant: true };
|
|
369
|
+
|
|
370
|
+
let entries;
|
|
371
|
+
try {
|
|
372
|
+
entries = await fs.readdir(resolvedProfileDir, { withFileTypes: true });
|
|
373
|
+
} catch {
|
|
374
|
+
// Absent (ENOENT) or unreadable (EACCES) profile dir — nothing to warn about.
|
|
375
|
+
return emptyResult;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const jsonFiles = entries
|
|
379
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
380
|
+
.map((entry) => entry.name)
|
|
381
|
+
.sort();
|
|
382
|
+
|
|
383
|
+
if (jsonFiles.length === 0) {
|
|
384
|
+
return emptyResult;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const scanned = [];
|
|
388
|
+
for (const name of jsonFiles) {
|
|
389
|
+
const file = path.join(resolvedProfileDir, name);
|
|
390
|
+
let raw;
|
|
391
|
+
try {
|
|
392
|
+
raw = await fs.readFile(file, 'utf8');
|
|
393
|
+
} catch {
|
|
394
|
+
scanned.push({ file, validJson: false, hasEnvBlock: false, missing: [...managedKeys] });
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let parsed;
|
|
399
|
+
try {
|
|
400
|
+
parsed = JSON.parse(raw);
|
|
401
|
+
} catch {
|
|
402
|
+
scanned.push({ file, validJson: false, hasEnvBlock: false, missing: [...managedKeys] });
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const envBlock = parsed?.env;
|
|
407
|
+
const hasEnvBlock =
|
|
408
|
+
Boolean(envBlock) && typeof envBlock === 'object' && !Array.isArray(envBlock);
|
|
409
|
+
const missing = hasEnvBlock
|
|
410
|
+
? managedKeys.filter((key) => !(key in envBlock))
|
|
411
|
+
: [...managedKeys];
|
|
412
|
+
|
|
413
|
+
scanned.push({ file, validJson: true, hasEnvBlock, missing });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
profileDir: resolvedProfileDir,
|
|
418
|
+
scanned,
|
|
419
|
+
compliant: scanned.every((entry) => entry.missing.length === 0),
|
|
420
|
+
};
|
|
421
|
+
}
|
package/src/index/buildIndex.js
CHANGED
|
@@ -36,12 +36,15 @@ const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
|
|
|
36
36
|
const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.sql', '.json', '.yaml', '.yml', '.md', '.sh']);
|
|
37
37
|
const DISCOVERED_EXTENSIONS = new Set([...TRACKED_EXTENSIONS, ...STYLE_EXTENSIONS]);
|
|
38
38
|
export const DEFAULT_INDEX_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
39
|
+
// Bumped only when the snapshot shape itself changes; consumers must reject
|
|
40
|
+
// snapshots from a different version instead of trusting unknown fields.
|
|
41
|
+
export const DISCOVERY_SNAPSHOT_VERSION = 1;
|
|
39
42
|
const INDEX_PARSE_BATCH_SIZE = 8;
|
|
40
43
|
const CALL_IGNORE_WORDS = new Set([
|
|
41
44
|
'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof',
|
|
42
45
|
]);
|
|
43
46
|
|
|
44
|
-
export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
|
|
47
|
+
export async function buildCodeIndex({ rootDir = process.cwd(), discoverySnapshot = null } = {}) {
|
|
45
48
|
const absoluteRoot = path.resolve(rootDir);
|
|
46
49
|
const indexDir = getIndexDir(absoluteRoot);
|
|
47
50
|
|
|
@@ -55,7 +58,14 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
|
|
|
55
58
|
previousCodeFileRecords.map((item) => [item.filePath, { mtimeMs: Number(item.mtimeMs ?? -1), size: Number(item.size ?? -1) }]),
|
|
56
59
|
);
|
|
57
60
|
|
|
58
|
-
|
|
61
|
+
// Reuse the caller's discovery snapshot when it describes exactly this root
|
|
62
|
+
// under the current schema and snapshot version; anything else (foreign root,
|
|
63
|
+
// stale schema, wrong shape) is rejected and the repository is rediscovered
|
|
64
|
+
// once here. This is what keeps a stale-check + rebuild refresh at a single
|
|
65
|
+
// enumeration instead of two.
|
|
66
|
+
const discoveredFiles = isDiscoverySnapshotUsable(discoverySnapshot, absoluteRoot)
|
|
67
|
+
? discoverySnapshot.files
|
|
68
|
+
: await discoverProjectFiles(absoluteRoot);
|
|
59
69
|
const sourceFingerprint = createSourceFingerprint(absoluteRoot, discoveredFiles);
|
|
60
70
|
const styleFilePaths = discoveredFiles
|
|
61
71
|
.map((entry) => {
|
|
@@ -1071,6 +1081,27 @@ async function discoverProjectFiles(rootDir) {
|
|
|
1071
1081
|
return collectFiles([rootDir]);
|
|
1072
1082
|
}
|
|
1073
1083
|
|
|
1084
|
+
function createDiscoverySnapshot(rootDir, files, fingerprint) {
|
|
1085
|
+
// Frozen: the snapshot must stay byte-stable between the staleness inspection
|
|
1086
|
+
// and the build that consumes it, and callers must not mutate shared state.
|
|
1087
|
+
return Object.freeze({
|
|
1088
|
+
snapshotVersion: DISCOVERY_SNAPSHOT_VERSION,
|
|
1089
|
+
schemaVersion: INDEX_SCHEMA_VERSION,
|
|
1090
|
+
rootDir,
|
|
1091
|
+
fingerprint,
|
|
1092
|
+
files: Object.freeze(files.slice()),
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function isDiscoverySnapshotUsable(discoverySnapshot, absoluteRoot) {
|
|
1097
|
+
return Boolean(discoverySnapshot)
|
|
1098
|
+
&& discoverySnapshot.snapshotVersion === DISCOVERY_SNAPSHOT_VERSION
|
|
1099
|
+
&& discoverySnapshot.schemaVersion === INDEX_SCHEMA_VERSION
|
|
1100
|
+
&& typeof discoverySnapshot.rootDir === 'string'
|
|
1101
|
+
&& path.resolve(discoverySnapshot.rootDir) === absoluteRoot
|
|
1102
|
+
&& Array.isArray(discoverySnapshot.files);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1074
1105
|
function runGit(rootDir, args) {
|
|
1075
1106
|
const result = spawnSync('git', args, {
|
|
1076
1107
|
cwd: rootDir,
|
|
@@ -1810,36 +1841,55 @@ export async function getIndexArtifactGeneratedAt({
|
|
|
1810
1841
|
return parseArtifactGeneratedAt(artifact);
|
|
1811
1842
|
}
|
|
1812
1843
|
|
|
1813
|
-
|
|
1814
|
-
rootDir = process.cwd(),
|
|
1815
|
-
maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
1816
|
-
now = Date.now(),
|
|
1817
|
-
generatedAtMs = null,
|
|
1818
|
-
} = {}) {
|
|
1844
|
+
async function evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot }) {
|
|
1819
1845
|
const absoluteRoot = path.resolve(rootDir);
|
|
1820
1846
|
const metaArtifact = await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.meta);
|
|
1821
1847
|
const effectiveGeneratedAtMs = Number.isFinite(generatedAtMs)
|
|
1822
1848
|
? generatedAtMs
|
|
1823
1849
|
: parseArtifactGeneratedAt(metaArtifact);
|
|
1824
|
-
if (effectiveGeneratedAtMs === null) {
|
|
1825
|
-
return true;
|
|
1826
|
-
}
|
|
1827
1850
|
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1851
|
+
const staleWithoutEnumeration = effectiveGeneratedAtMs === null
|
|
1852
|
+
|| typeof maxAgeMs !== 'number'
|
|
1853
|
+
|| Number.isNaN(maxAgeMs)
|
|
1854
|
+
|| maxAgeMs < 0
|
|
1855
|
+
|| (now - effectiveGeneratedAtMs) >= maxAgeMs
|
|
1856
|
+
|| !metaArtifact?.sourceFingerprint;
|
|
1831
1857
|
|
|
1832
|
-
|
|
1833
|
-
|
|
1858
|
+
// Legacy boolean callers (query-index, triage, verify-context) stop here and
|
|
1859
|
+
// let buildCodeIndex do its own discovery when a rebuild is needed.
|
|
1860
|
+
if (staleWithoutEnumeration && !enumerateForSnapshot) {
|
|
1861
|
+
return { stale: true, snapshot: null };
|
|
1834
1862
|
}
|
|
1835
1863
|
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1864
|
+
// Exactly one enumeration per inspection. In the refresh flow this snapshot is
|
|
1865
|
+
// handed to buildCodeIndex, so stale-check + rebuild enumerates the repository
|
|
1866
|
+
// once instead of twice.
|
|
1867
|
+
const discoveredFiles = await discoverProjectFiles(absoluteRoot);
|
|
1868
|
+
const fingerprint = createSourceFingerprint(absoluteRoot, discoveredFiles);
|
|
1839
1869
|
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1870
|
+
return {
|
|
1871
|
+
stale: staleWithoutEnumeration
|
|
1872
|
+
? true
|
|
1873
|
+
: !areSourceFingerprintsEqual(metaArtifact.sourceFingerprint, fingerprint),
|
|
1874
|
+
snapshot: createDiscoverySnapshot(absoluteRoot, discoveredFiles, fingerprint),
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
export async function inspectIndexStaleness({
|
|
1879
|
+
rootDir = process.cwd(),
|
|
1880
|
+
maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
1881
|
+
now = Date.now(),
|
|
1882
|
+
generatedAtMs = null,
|
|
1883
|
+
} = {}) {
|
|
1884
|
+
return evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: true });
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
export async function isIndexStale({
|
|
1888
|
+
rootDir = process.cwd(),
|
|
1889
|
+
maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
1890
|
+
now = Date.now(),
|
|
1891
|
+
generatedAtMs = null,
|
|
1892
|
+
} = {}) {
|
|
1893
|
+
const { stale } = await evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: false });
|
|
1894
|
+
return stale;
|
|
1845
1895
|
}
|
|
@@ -2,13 +2,32 @@
|
|
|
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
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
# shellcheck source=/dev/null
|
|
7
|
+
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
8
|
+
trap ukit_cleanup_hook_input EXIT
|
|
9
|
+
# Bounded stdin (H01): stage before anything reads it; the shell var is capped.
|
|
10
|
+
ukit_stage_hook_input 2097152 truncate || exit 0
|
|
11
|
+
else
|
|
12
|
+
# Runtime helper missing (pre-install tree): bounded inline staging keeps the
|
|
13
|
+
# wrapper's own fail-loud/advisory behavior below alive.
|
|
14
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
15
|
+
head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
|
|
16
|
+
cat >/dev/null 2>&1
|
|
17
|
+
if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
|
|
18
|
+
truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
19
|
+
fi
|
|
20
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
21
|
+
fi
|
|
22
|
+
INPUT="$(cat "$UKIT_INPUT_FILE")"
|
|
6
23
|
# jq is absent on stock macOS. Keep jq as the low-latency normal path, but fall back to
|
|
7
24
|
# UKit's required Node runtime so safe commands still receive managed metadata.
|
|
8
25
|
if command -v jq >/dev/null 2>&1 && jq --version >/dev/null 2>&1; then
|
|
9
26
|
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')
|
|
10
27
|
else
|
|
11
28
|
COMMAND=$(printf '%s' "$INPUT" | node -e '
|
|
29
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
|
|
30
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
12
31
|
const chunks = [];
|
|
13
32
|
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
14
33
|
process.stdin.on("end", () => {
|
|
@@ -78,6 +97,10 @@ MAX_RULES="${CLAUDE_AUTO_ALLOW_MAX_RULES:-150}"
|
|
|
78
97
|
# Liveness over strictness: if the lock cannot be acquired within maxWaitMs the mutation
|
|
79
98
|
# runs anyway (pre-lock behaviour) — this hook must never hang a PreToolUse chain.
|
|
80
99
|
node -e '
|
|
100
|
+
// Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
|
|
101
|
+
// watchdog can never fire while the mutation is still legally waiting for the lock.
|
|
102
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
|
|
103
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
81
104
|
const crypto = require("crypto");
|
|
82
105
|
const fs = require("fs");
|
|
83
106
|
const path = require("path");
|
|
@@ -14,6 +14,10 @@ fi
|
|
|
14
14
|
NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
15
15
|
|
|
16
16
|
node -e '
|
|
17
|
+
// Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
|
|
18
|
+
// watchdog can never fire while the mutation is still legally waiting for the lock.
|
|
19
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
|
|
20
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
17
21
|
const crypto = require("crypto");
|
|
18
22
|
const fs = require("fs");
|
|
19
23
|
const path = require("path");
|
|
@@ -2,13 +2,32 @@
|
|
|
2
2
|
# PreToolUse hook: Block dangerous bash commands
|
|
3
3
|
# Matched on: Bash
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
# shellcheck source=/dev/null
|
|
7
|
+
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
8
|
+
trap ukit_cleanup_hook_input EXIT
|
|
9
|
+
# Bounded stdin (H01): stage before anything reads it; the shell var is capped.
|
|
10
|
+
ukit_stage_hook_input 2097152 truncate || exit 0
|
|
11
|
+
else
|
|
12
|
+
# Runtime helper missing (pre-install tree): bounded inline staging keeps the
|
|
13
|
+
# wrapper's own fail-loud/advisory behavior below alive.
|
|
14
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
15
|
+
head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
|
|
16
|
+
cat >/dev/null 2>&1
|
|
17
|
+
if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
|
|
18
|
+
truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
19
|
+
fi
|
|
20
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
21
|
+
fi
|
|
22
|
+
INPUT="$(cat "$UKIT_INPUT_FILE")"
|
|
6
23
|
# jq is not installed on stock macOS. Keep jq as the low-latency normal path, but use
|
|
7
24
|
# UKit's required Node runtime as a fallback so its absence cannot silently disable the gate.
|
|
8
25
|
if command -v jq >/dev/null 2>&1 && jq --version >/dev/null 2>&1; then
|
|
9
26
|
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')
|
|
10
27
|
else
|
|
11
28
|
COMMAND=$(printf '%s' "$INPUT" | node -e '
|
|
29
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
|
|
30
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
12
31
|
const chunks = [];
|
|
13
32
|
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
14
33
|
process.stdin.on("end", () => {
|
|
@@ -2,7 +2,24 @@
|
|
|
2
2
|
# Stop hook: block premature terminal stops while routed completion evidence is missing.
|
|
3
3
|
# ADVISORY ONLY — always exit 0. A missing or failing runtime must be loud, not a silent pass.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
# shellcheck source=/dev/null
|
|
7
|
+
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
8
|
+
trap ukit_cleanup_hook_input EXIT
|
|
9
|
+
# Bounded stdin (H01): stage before anything reads it; the shell var is capped.
|
|
10
|
+
ukit_stage_hook_input 2097152 truncate || exit 0
|
|
11
|
+
else
|
|
12
|
+
# Runtime helper missing (pre-install tree): bounded inline staging keeps the
|
|
13
|
+
# wrapper's own fail-loud/advisory behavior below alive.
|
|
14
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
15
|
+
head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
|
|
16
|
+
cat >/dev/null 2>&1
|
|
17
|
+
if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
|
|
18
|
+
truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
19
|
+
fi
|
|
20
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
21
|
+
fi
|
|
22
|
+
INPUT="$(cat "$UKIT_INPUT_FILE")"
|
|
6
23
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
7
24
|
SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/execution-ledger.mjs"
|
|
8
25
|
|
|
@@ -11,7 +28,7 @@ if [ ! -f "$SCRIPT" ]; then
|
|
|
11
28
|
exit 0
|
|
12
29
|
fi
|
|
13
30
|
|
|
14
|
-
OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code node "$SCRIPT" --evaluate-stop)
|
|
31
|
+
OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT" --evaluate-stop)
|
|
15
32
|
STATUS=$?
|
|
16
33
|
|
|
17
34
|
if [ "$STATUS" -ne 0 ]; then
|
|
@@ -9,8 +9,23 @@ HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
|
9
9
|
SCRIPT_PATH="$HOOK_DIR/../ukit/runtime/output-compression.mjs"
|
|
10
10
|
|
|
11
11
|
if [ -f "$SCRIPT_PATH" ]; then
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# shellcheck source=/dev/null
|
|
13
|
+
if source "$HOOK_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
14
|
+
trap ukit_cleanup_hook_input EXIT
|
|
15
|
+
# Bounded stdin (H01): compressed tool output can be large, so the staged
|
|
16
|
+
# file is the transport and the shell var only mirrors the capped payload.
|
|
17
|
+
ukit_stage_hook_input 33554432 temp-file || exit 0
|
|
18
|
+
else
|
|
19
|
+
# Runtime helper missing (pre-install tree): bounded inline staging keeps the
|
|
20
|
+
# wrapper's own fail-loud/advisory behavior below alive.
|
|
21
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
22
|
+
head -c 33554433 > "$UKIT_INPUT_FILE" 2>/dev/null
|
|
23
|
+
cat >/dev/null 2>&1
|
|
24
|
+
truncate -s 33554432 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
25
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
26
|
+
fi
|
|
27
|
+
INPUT="$(cat "$UKIT_INPUT_FILE")"
|
|
28
|
+
printf '%s' "$INPUT" | UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-8000}" node "$SCRIPT_PATH"
|
|
14
29
|
exit $?
|
|
15
30
|
fi
|
|
16
31
|
|
|
@@ -29,18 +29,37 @@
|
|
|
29
29
|
# Config toggle: compact.hardCapBlock (default true). Set to false only to debug this
|
|
30
30
|
# gate itself; it must not become a normal escape hatch.
|
|
31
31
|
|
|
32
|
-
INPUT=$(cat)
|
|
33
32
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
34
33
|
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
34
|
+
# shellcheck source=/dev/null
|
|
35
|
+
if source "$HOOK_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
36
|
+
trap ukit_cleanup_hook_input EXIT
|
|
37
|
+
# Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
|
|
38
|
+
ukit_stage_hook_input 2097152 truncate || exit 0
|
|
39
|
+
else
|
|
40
|
+
# Runtime helper missing (pre-install tree): bounded inline staging keeps the
|
|
41
|
+
# advisory/fail-open behavior below alive with a capped payload file.
|
|
42
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
43
|
+
head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
|
|
44
|
+
cat >/dev/null 2>&1
|
|
45
|
+
if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
|
|
46
|
+
truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
47
|
+
fi
|
|
48
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
49
|
+
fi
|
|
35
50
|
|
|
36
|
-
|
|
51
|
+
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" HOOK_DIR="$HOOK_DIR" node <<'NODE'
|
|
52
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
53
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
37
54
|
const fs = require('fs');
|
|
38
55
|
const path = require('path');
|
|
39
56
|
const { pathToFileURL } = require('url');
|
|
40
57
|
|
|
58
|
+
let rawInput = '';
|
|
59
|
+
try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
|
|
41
60
|
const payload = (() => {
|
|
42
61
|
try {
|
|
43
|
-
const parsed = JSON.parse(
|
|
62
|
+
const parsed = JSON.parse(rawInput);
|
|
44
63
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
45
64
|
} catch {
|
|
46
65
|
return {};
|