@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.
- package/manifests/platform.full.yaml +19 -111
- package/package.json +2 -1
- package/scripts/index/refresh-index.mjs +47 -22
- package/src/core/compact/threshold.js +36 -6
- package/src/diagnostics/classifyHang.js +246 -0
- package/src/index/buildIndex.js +1033 -62
- package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
- package/templates/.claude/hooks/block-dangerous.sh +31 -5
- package/templates/.claude/hooks/completion-gate.sh +51 -10
- package/templates/.claude/hooks/compress-output.sh +38 -6
- package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
- package/templates/.claude/hooks/context-window-guard.sh +128 -18
- package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
- package/templates/.claude/hooks/handoff-resume.sh +31 -5
- package/templates/.claude/hooks/post-edit-verify.sh +31 -5
- package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
- package/templates/.claude/hooks/protect-files.sh +31 -5
- package/templates/.claude/hooks/record-execution.sh +31 -5
- package/templates/.claude/hooks/sensitive-data-guard.sh +44 -8
- package/templates/.claude/hooks/skill-router.sh +31 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
- package/templates/.claude/hooks/task-watchdog.sh +108 -123
- package/templates/.claude/hooks/verification-guard.sh +107 -112
- package/templates/.claude/hooks/vision-router.sh +49 -13
- package/templates/.claude/settings.json +0 -5
- package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
- package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
- package/templates/.claude/ukit/index/route-task.mjs +610 -4
- package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
- package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
- package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
- package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
- package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
|
@@ -1059,7 +1059,7 @@ items:
|
|
|
1059
1059
|
sourceTemplate: .claude/hooks/record-execution.sh
|
|
1060
1060
|
targetPath: .claude/hooks/record-execution.sh
|
|
1061
1061
|
requires:
|
|
1062
|
-
- ukit-runtime-
|
|
1062
|
+
- ukit-runtime-scripts
|
|
1063
1063
|
mergeStrategy: overwrite_with_backup
|
|
1064
1064
|
variables: []
|
|
1065
1065
|
enabledByDefault: true
|
|
@@ -1071,7 +1071,7 @@ items:
|
|
|
1071
1071
|
sourceTemplate: .claude/hooks/completion-gate.sh
|
|
1072
1072
|
targetPath: .claude/hooks/completion-gate.sh
|
|
1073
1073
|
requires:
|
|
1074
|
-
- ukit-runtime-
|
|
1074
|
+
- ukit-runtime-scripts
|
|
1075
1075
|
mergeStrategy: overwrite_with_backup
|
|
1076
1076
|
variables: []
|
|
1077
1077
|
enabledByDefault: true
|
|
@@ -1139,7 +1139,7 @@ items:
|
|
|
1139
1139
|
targetPath: .claude/hooks/skill-router.sh
|
|
1140
1140
|
requires:
|
|
1141
1141
|
- ukit-index-route-catalog-script
|
|
1142
|
-
- ukit-runtime-
|
|
1142
|
+
- ukit-runtime-scripts
|
|
1143
1143
|
mergeStrategy: overwrite_with_backup
|
|
1144
1144
|
variables: []
|
|
1145
1145
|
enabledByDefault: true
|
|
@@ -1164,7 +1164,7 @@ items:
|
|
|
1164
1164
|
sourceTemplate: .claude/hooks/context-hardcap-gate.sh
|
|
1165
1165
|
targetPath: .claude/hooks/context-hardcap-gate.sh
|
|
1166
1166
|
requires:
|
|
1167
|
-
- ukit-runtime-
|
|
1167
|
+
- ukit-runtime-scripts
|
|
1168
1168
|
mergeStrategy: overwrite_with_backup
|
|
1169
1169
|
variables: []
|
|
1170
1170
|
enabledByDefault: true
|
|
@@ -1204,10 +1204,16 @@ items:
|
|
|
1204
1204
|
packs:
|
|
1205
1205
|
- core
|
|
1206
1206
|
|
|
1207
|
-
|
|
1207
|
+
# One directory item ships the whole runtime dir. autoDiscoverTemplates:false silently
|
|
1208
|
+
# skips any template file without an explicit item, which is exactly how later-wave
|
|
1209
|
+
# runtime modules (async-lock.mjs, hook-process.mjs, transcript-tail.mjs, hook-input.*)
|
|
1210
|
+
# never reached installed projects and broke execution-ledger.mjs imports at hook time.
|
|
1211
|
+
# A directory scan keeps every new runtime module shipping without a manifest edit; the
|
|
1212
|
+
# walker preserves per-file modes (hook-input.sh keeps its exec bit).
|
|
1213
|
+
- id: ukit-runtime-scripts
|
|
1208
1214
|
type: config
|
|
1209
|
-
sourceTemplate: .claude/ukit/runtime
|
|
1210
|
-
targetPath: .claude/ukit/runtime
|
|
1215
|
+
sourceTemplate: .claude/ukit/runtime
|
|
1216
|
+
targetPath: .claude/ukit/runtime
|
|
1211
1217
|
requires: []
|
|
1212
1218
|
mergeStrategy: overwrite_with_backup
|
|
1213
1219
|
variables: []
|
|
@@ -1231,7 +1237,7 @@ items:
|
|
|
1231
1237
|
sourceTemplate: .claude/hooks/compress-output.sh
|
|
1232
1238
|
targetPath: .claude/hooks/compress-output.sh
|
|
1233
1239
|
requires:
|
|
1234
|
-
- ukit-runtime-
|
|
1240
|
+
- ukit-runtime-scripts
|
|
1235
1241
|
mergeStrategy: overwrite_with_backup
|
|
1236
1242
|
variables: []
|
|
1237
1243
|
enabledByDefault: true
|
|
@@ -1273,58 +1279,12 @@ items:
|
|
|
1273
1279
|
packs:
|
|
1274
1280
|
- core
|
|
1275
1281
|
|
|
1276
|
-
- id: ukit-runtime-text-profile-script
|
|
1277
|
-
type: config
|
|
1278
|
-
sourceTemplate: .claude/ukit/runtime/text-profile.mjs
|
|
1279
|
-
targetPath: .claude/ukit/runtime/text-profile.mjs
|
|
1280
|
-
requires: []
|
|
1281
|
-
mergeStrategy: overwrite_with_backup
|
|
1282
|
-
variables: []
|
|
1283
|
-
enabledByDefault: true
|
|
1284
|
-
packs:
|
|
1285
|
-
- core
|
|
1286
|
-
|
|
1287
|
-
- id: ukit-runtime-execution-ledger-script
|
|
1288
|
-
type: config
|
|
1289
|
-
sourceTemplate: .claude/ukit/runtime/execution-ledger.mjs
|
|
1290
|
-
targetPath: .claude/ukit/runtime/execution-ledger.mjs
|
|
1291
|
-
requires: []
|
|
1292
|
-
mergeStrategy: overwrite_with_backup
|
|
1293
|
-
variables: []
|
|
1294
|
-
enabledByDefault: true
|
|
1295
|
-
packs:
|
|
1296
|
-
- core
|
|
1297
|
-
|
|
1298
|
-
- id: ukit-runtime-hook-chain-runner-script
|
|
1299
|
-
type: config
|
|
1300
|
-
sourceTemplate: .claude/ukit/runtime/hook-chain-runner.mjs
|
|
1301
|
-
targetPath: .claude/ukit/runtime/hook-chain-runner.mjs
|
|
1302
|
-
requires: []
|
|
1303
|
-
mergeStrategy: overwrite_with_backup
|
|
1304
|
-
variables: []
|
|
1305
|
-
enabledByDefault: true
|
|
1306
|
-
packs:
|
|
1307
|
-
- core
|
|
1308
|
-
|
|
1309
|
-
- id: ukit-runtime-safe-patch-core-script
|
|
1310
|
-
type: config
|
|
1311
|
-
sourceTemplate: .claude/ukit/runtime/safe-patch-core.mjs
|
|
1312
|
-
targetPath: .claude/ukit/runtime/safe-patch-core.mjs
|
|
1313
|
-
requires:
|
|
1314
|
-
- ukit-runtime-text-profile-script
|
|
1315
|
-
mergeStrategy: overwrite_with_backup
|
|
1316
|
-
variables: []
|
|
1317
|
-
enabledByDefault: true
|
|
1318
|
-
packs:
|
|
1319
|
-
- core
|
|
1320
|
-
|
|
1321
1282
|
- id: ukit-index-anchor-search-script
|
|
1322
1283
|
type: config
|
|
1323
1284
|
sourceTemplate: .claude/ukit/index/anchor-search.mjs
|
|
1324
1285
|
targetPath: .claude/ukit/index/anchor-search.mjs
|
|
1325
1286
|
requires:
|
|
1326
|
-
- ukit-runtime-
|
|
1327
|
-
- ukit-runtime-safe-patch-core-script
|
|
1287
|
+
- ukit-runtime-scripts
|
|
1328
1288
|
mergeStrategy: overwrite_with_backup
|
|
1329
1289
|
variables: []
|
|
1330
1290
|
enabledByDefault: true
|
|
@@ -1348,8 +1308,7 @@ items:
|
|
|
1348
1308
|
sourceTemplate: .claude/ukit/index/provision-worktree.mjs
|
|
1349
1309
|
targetPath: .claude/ukit/index/provision-worktree.mjs
|
|
1350
1310
|
requires:
|
|
1351
|
-
- ukit-runtime-
|
|
1352
|
-
- ukit-runtime-safe-patch-core-script
|
|
1311
|
+
- ukit-runtime-scripts
|
|
1353
1312
|
mergeStrategy: overwrite_with_backup
|
|
1354
1313
|
variables: []
|
|
1355
1314
|
enabledByDefault: true
|
|
@@ -1361,8 +1320,7 @@ items:
|
|
|
1361
1320
|
sourceTemplate: .claude/ukit/index/stale-spec-check.mjs
|
|
1362
1321
|
targetPath: .claude/ukit/index/stale-spec-check.mjs
|
|
1363
1322
|
requires:
|
|
1364
|
-
- ukit-runtime-
|
|
1365
|
-
- ukit-runtime-safe-patch-core-script
|
|
1323
|
+
- ukit-runtime-scripts
|
|
1366
1324
|
mergeStrategy: overwrite_with_backup
|
|
1367
1325
|
variables: []
|
|
1368
1326
|
enabledByDefault: true
|
|
@@ -1374,8 +1332,7 @@ items:
|
|
|
1374
1332
|
sourceTemplate: .claude/ukit/index/pre-edit-backup.mjs
|
|
1375
1333
|
targetPath: .claude/ukit/index/pre-edit-backup.mjs
|
|
1376
1334
|
requires:
|
|
1377
|
-
- ukit-runtime-
|
|
1378
|
-
- ukit-runtime-safe-patch-core-script
|
|
1335
|
+
- ukit-runtime-scripts
|
|
1379
1336
|
mergeStrategy: overwrite_with_backup
|
|
1380
1337
|
variables: []
|
|
1381
1338
|
enabledByDefault: true
|
|
@@ -1387,8 +1344,7 @@ items:
|
|
|
1387
1344
|
sourceTemplate: .claude/ukit/index/post-edit-verify.mjs
|
|
1388
1345
|
targetPath: .claude/ukit/index/post-edit-verify.mjs
|
|
1389
1346
|
requires:
|
|
1390
|
-
- ukit-runtime-
|
|
1391
|
-
- ukit-runtime-safe-patch-core-script
|
|
1347
|
+
- ukit-runtime-scripts
|
|
1392
1348
|
mergeStrategy: overwrite_with_backup
|
|
1393
1349
|
variables: []
|
|
1394
1350
|
enabledByDefault: true
|
|
@@ -1549,54 +1505,6 @@ items:
|
|
|
1549
1505
|
packs:
|
|
1550
1506
|
- core
|
|
1551
1507
|
|
|
1552
|
-
- id: ukit-runtime-token-utils-script
|
|
1553
|
-
type: config
|
|
1554
|
-
sourceTemplate: .claude/ukit/runtime/token-utils.mjs
|
|
1555
|
-
targetPath: .claude/ukit/runtime/token-utils.mjs
|
|
1556
|
-
mergeStrategy: overwrite_with_backup
|
|
1557
|
-
variables: []
|
|
1558
|
-
enabledByDefault: true
|
|
1559
|
-
packs:
|
|
1560
|
-
- core
|
|
1561
|
-
|
|
1562
|
-
- id: ukit-runtime-compact-threshold-script
|
|
1563
|
-
type: config
|
|
1564
|
-
sourceTemplate: .claude/ukit/runtime/compact-threshold.mjs
|
|
1565
|
-
targetPath: .claude/ukit/runtime/compact-threshold.mjs
|
|
1566
|
-
requires:
|
|
1567
|
-
- ukit-runtime-token-utils-script
|
|
1568
|
-
mergeStrategy: overwrite_with_backup
|
|
1569
|
-
variables: []
|
|
1570
|
-
enabledByDefault: true
|
|
1571
|
-
packs:
|
|
1572
|
-
- core
|
|
1573
|
-
|
|
1574
|
-
- id: ukit-runtime-reinject-context-script
|
|
1575
|
-
type: config
|
|
1576
|
-
sourceTemplate: .claude/ukit/runtime/reinject-context.mjs
|
|
1577
|
-
targetPath: .claude/ukit/runtime/reinject-context.mjs
|
|
1578
|
-
requires:
|
|
1579
|
-
- ukit-runtime-token-utils-script
|
|
1580
|
-
- ukit-runtime-compact-threshold-script
|
|
1581
|
-
mergeStrategy: overwrite_with_backup
|
|
1582
|
-
variables: []
|
|
1583
|
-
enabledByDefault: true
|
|
1584
|
-
packs:
|
|
1585
|
-
- core
|
|
1586
|
-
|
|
1587
|
-
- id: ukit-runtime-output-compression-script
|
|
1588
|
-
type: config
|
|
1589
|
-
sourceTemplate: .claude/ukit/runtime/output-compression.mjs
|
|
1590
|
-
targetPath: .claude/ukit/runtime/output-compression.mjs
|
|
1591
|
-
requires:
|
|
1592
|
-
- ukit-runtime-token-utils-script
|
|
1593
|
-
- ukit-runtime-compact-threshold-script
|
|
1594
|
-
mergeStrategy: overwrite_with_backup
|
|
1595
|
-
variables: []
|
|
1596
|
-
enabledByDefault: true
|
|
1597
|
-
packs:
|
|
1598
|
-
- core
|
|
1599
|
-
|
|
1600
1508
|
- id: ukit-index-reset-auto-permissions-script
|
|
1601
1509
|
type: config
|
|
1602
1510
|
sourceTemplate: .claude/ukit/index/reset-auto-permissions.mjs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ngockhoale/ukit",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.3",
|
|
4
4
|
"description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"test:release-core": "vitest run --exclude tests/integration/packageArtifact.test.js",
|
|
60
60
|
"release:verify": "node ./scripts/release/verify-release.mjs",
|
|
61
61
|
"test": "vitest run",
|
|
62
|
+
"test:liveness": "vitest run tests/liveness/",
|
|
62
63
|
"test:watch": "vitest"
|
|
63
64
|
},
|
|
64
65
|
"engines": {
|
|
@@ -19,39 +19,64 @@ const changedFiles = changedArg
|
|
|
19
19
|
|
|
20
20
|
const force = readBooleanFlag(args, '--force');
|
|
21
21
|
const lastRefreshMs = await getLastRefreshTime(rootDir);
|
|
22
|
-
const staleness = force
|
|
23
|
-
? null
|
|
24
|
-
: await inspectIndexStaleness({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS });
|
|
25
|
-
const stale = force || staleness.stale;
|
|
26
22
|
|
|
27
|
-
if (!
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
|
|
34
|
-
}
|
|
35
|
-
console.log(`root: ${rootDir}`);
|
|
36
|
-
} else {
|
|
37
|
-
// Reuse the staleness check's discovery snapshot: one enumeration per refresh.
|
|
38
|
-
const summary = await buildCodeIndex({
|
|
39
|
-
rootDir,
|
|
40
|
-
discoverySnapshot: staleness ? staleness.snapshot : null,
|
|
41
|
-
});
|
|
23
|
+
if (changedFiles.length > 0 && !force) {
|
|
24
|
+
// Hints carry the change set, so no staleness enumeration is needed:
|
|
25
|
+
// buildCodeIndex merges the hinted paths into the existing artifacts and
|
|
26
|
+
// falls back to the bounded full discovery path on its own when the hints
|
|
27
|
+
// are unsafe or the previous artifacts cannot be merged (TASK-023).
|
|
28
|
+
const summary = await buildCodeIndex({ rootDir, changedFiles });
|
|
42
29
|
|
|
43
30
|
console.log('[index:refresh] completed');
|
|
31
|
+
console.log(`mode: ${summary.mode}`);
|
|
44
32
|
if (lastRefreshMs !== null) {
|
|
45
33
|
console.log(`lastRefreshBefore: ${new Date(lastRefreshMs).toISOString()}`);
|
|
46
34
|
}
|
|
47
|
-
|
|
48
|
-
console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
|
|
49
|
-
}
|
|
35
|
+
console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
|
|
50
36
|
console.log(`root: ${rootDir}`);
|
|
51
37
|
console.log(`files: ${summary.fileCount}`);
|
|
52
38
|
console.log(`parsed: ${summary.parsedCodeFileCount}`);
|
|
53
39
|
console.log(`reused: ${summary.reusedCodeFileCount}`);
|
|
40
|
+
if (summary.removedFiles.length > 0) {
|
|
41
|
+
console.log(`removed: ${summary.removedFiles.length}`);
|
|
42
|
+
}
|
|
54
43
|
console.log(`indexDir: ${summary.indexDir}`);
|
|
44
|
+
} else {
|
|
45
|
+
const staleness = force
|
|
46
|
+
? null
|
|
47
|
+
: await inspectIndexStaleness({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS });
|
|
48
|
+
const stale = force || staleness.stale;
|
|
49
|
+
|
|
50
|
+
if (!stale) {
|
|
51
|
+
console.log('[index:refresh] skipped (cache fresh)');
|
|
52
|
+
if (lastRefreshMs !== null) {
|
|
53
|
+
console.log(`lastRefresh: ${new Date(lastRefreshMs).toISOString()}`);
|
|
54
|
+
}
|
|
55
|
+
if (changedFiles.length > 0) {
|
|
56
|
+
console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
|
|
57
|
+
}
|
|
58
|
+
console.log(`root: ${rootDir}`);
|
|
59
|
+
} else {
|
|
60
|
+
// Reuse the staleness check's discovery snapshot: one enumeration per refresh.
|
|
61
|
+
const summary = await buildCodeIndex({
|
|
62
|
+
rootDir,
|
|
63
|
+
discoverySnapshot: staleness ? staleness.snapshot : null,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
console.log('[index:refresh] completed');
|
|
67
|
+
console.log(`mode: ${summary.mode}`);
|
|
68
|
+
if (lastRefreshMs !== null) {
|
|
69
|
+
console.log(`lastRefreshBefore: ${new Date(lastRefreshMs).toISOString()}`);
|
|
70
|
+
}
|
|
71
|
+
if (changedFiles.length > 0) {
|
|
72
|
+
console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
|
|
73
|
+
}
|
|
74
|
+
console.log(`root: ${rootDir}`);
|
|
75
|
+
console.log(`files: ${summary.fileCount}`);
|
|
76
|
+
console.log(`parsed: ${summary.parsedCodeFileCount}`);
|
|
77
|
+
console.log(`reused: ${summary.reusedCodeFileCount}`);
|
|
78
|
+
console.log(`indexDir: ${summary.indexDir}`);
|
|
79
|
+
}
|
|
55
80
|
}
|
|
56
81
|
|
|
57
82
|
function readFlagValue(argv, flag) {
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { readJsonIfExists, writeJson, withFileLock } from '../fileOps.js';
|
|
2
|
+
// The installed runtime is the single implementation of capacity negotiation; this source
|
|
3
|
+
// twin consumes it rather than re-deriving the precedence rules. Both files ship together.
|
|
4
|
+
import {
|
|
5
|
+
readContextCapacityRecord,
|
|
6
|
+
SOFT_TO_CAP_RATIO,
|
|
7
|
+
} from '../../../templates/.claude/ukit/runtime/compact-threshold.mjs';
|
|
8
|
+
import { resolveContextCapTokens } from '../../../templates/.claude/ukit/runtime/context-capacity.mjs';
|
|
2
9
|
import { buildRuntimePaths } from '../runtimePaths.js';
|
|
3
10
|
import { buildCompactMachineKey, compressLine, estimateTokenCount } from '../token/index.js';
|
|
4
11
|
import { compactContextBlock } from './index.js';
|
|
@@ -436,22 +443,45 @@ function computeEstimatedTotalTokens({
|
|
|
436
443
|
return baselineTokens + estimatedContextTokens + windowTokens + sessionExcess;
|
|
437
444
|
}
|
|
438
445
|
|
|
446
|
+
// The record the advisory guard published for this project, or null when nothing has been
|
|
447
|
+
// negotiated yet (fresh install, non-project caller, tests).
|
|
448
|
+
function readNegotiatedCapacity(config = {}) {
|
|
449
|
+
const record = readContextCapacityRecord(config?.projectRoot);
|
|
450
|
+
if (!record) return null;
|
|
451
|
+
return resolveContextCapTokens({
|
|
452
|
+
env: process.env,
|
|
453
|
+
config,
|
|
454
|
+
modelMetadata: { model: record.model },
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
439
458
|
export function buildCompactThresholds(config = {}) {
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
459
|
+
const shippedHardCap = positiveInteger(
|
|
460
|
+
config?.compact?.hardCapTokens,
|
|
461
|
+
loadShippedCompactBudget().hardCapTokens,
|
|
443
462
|
);
|
|
463
|
+
const explicitSoftThreshold = finiteNumber(config?.compact?.tokenThreshold, 0);
|
|
464
|
+
// Negotiated capacity (H22): the guard publishes what it derived from the live route and
|
|
465
|
+
// this shared path consumes it; without a record the shipped tuning applies unchanged.
|
|
466
|
+
const negotiated = readNegotiatedCapacity(config);
|
|
467
|
+
const hardCapTokens = negotiated
|
|
468
|
+
? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
|
|
469
|
+
: shippedHardCap;
|
|
470
|
+
// An explicit operator tokenThreshold is honored as-is; otherwise the advisory phase is
|
|
471
|
+
// derived from the negotiating cap so it can never sit above the cap it precedes.
|
|
472
|
+
const softThreshold = explicitSoftThreshold > 0
|
|
473
|
+
? Math.max(1, explicitSoftThreshold)
|
|
474
|
+
: Math.max(1, Math.round(hardCapTokens * SOFT_TO_CAP_RATIO));
|
|
444
475
|
const hardThreshold = Math.max(softThreshold + 1, Math.round(softThreshold * 1.6));
|
|
445
476
|
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);
|
|
449
477
|
|
|
450
478
|
return {
|
|
451
479
|
softThreshold,
|
|
452
480
|
hardThreshold,
|
|
453
481
|
baselineTokens,
|
|
454
482
|
hardCapTokens,
|
|
483
|
+
capacitySource: negotiated?.capacity?.source ?? 'shipped',
|
|
484
|
+
capacityTokens: negotiated?.capacity?.tokens ?? null,
|
|
455
485
|
};
|
|
456
486
|
}
|
|
457
487
|
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// classifyHang.js — evidence-only hang classifier for the C19 liveness wave (TASK-032).
|
|
2
|
+
//
|
|
3
|
+
// What this is for: C19 removed a family of unbounded paths, and the remaining failure
|
|
4
|
+
// mode is telling ONE stall class apart from another. A prompt freeze used to be
|
|
5
|
+
// indistinguishable from an indexing freeze or a gateway stall, so operators had no
|
|
6
|
+
// evidence-based next step. This module turns an evidence bundle into exactly one
|
|
7
|
+
// declared lane, or `unknown` when the evidence cannot prove any of them.
|
|
8
|
+
//
|
|
9
|
+
// Contracts (asserted by tests/liveness/classifyHang.test.js):
|
|
10
|
+
// * PURE and CLOCK-FREE. Every rule is a function of the bundle only, so identical
|
|
11
|
+
// evidence always produces byte-identical output. No Date.now(), no randomness, no
|
|
12
|
+
// I/O — the caller supplies both the measurement and the clock.
|
|
13
|
+
// * NEVER GUESSES. A lane is only named when the evidence PROVES it:
|
|
14
|
+
// - `deadline` with zero survivors is a clean reap, not a process-tree leak;
|
|
15
|
+
// - survivors beside `failureKind: 'signal'` are NOT a leak: `signal` is documented
|
|
16
|
+
// by the runner as a signal IT DID NOT SEND, so there is no escalation to blame
|
|
17
|
+
// and the bundle is `unknown` (the runner's own `deadline` verdict is the only
|
|
18
|
+
// escalation proof);
|
|
19
|
+
// - a non-zero hook exit that finished inside its budget is a verdict, not a hang;
|
|
20
|
+
// - a non-typed index error is not a discovery deadline.
|
|
21
|
+
// Everything else is `unknown` with a concrete next check.
|
|
22
|
+
// * The UPSTREAM lane is never a hook. Stream idle with no hook active classifies
|
|
23
|
+
// `gateway-stream-idle`; the presence of hook evidence removes that lane entirely, so
|
|
24
|
+
// a gateway stall can no longer be reported as UKit's fault (or the reverse).
|
|
25
|
+
// * Exactly four fields, always: `{class, confidence, evidenceIds, recommendedNextCheck}`.
|
|
26
|
+
// `evidenceIds` names the fields the decision rests on, so a reviewer can re-derive it.
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The six declared stall lanes. One bundle resolves to at most one of these.
|
|
30
|
+
* Ordered by decision precedence (see LANE_PRECEDENCE) — the first lane whose evidence
|
|
31
|
+
* is present AND conclusive owns the classification.
|
|
32
|
+
*/
|
|
33
|
+
export const HANG_CLASSES = Object.freeze([
|
|
34
|
+
'process-tree-leak',
|
|
35
|
+
'hook-overrun',
|
|
36
|
+
'index-deadline',
|
|
37
|
+
'lock-contention',
|
|
38
|
+
'context-capacity',
|
|
39
|
+
'gateway-stream-idle',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** The explicit "could not prove a lane" result. Never a member of HANG_CLASSES. */
|
|
43
|
+
export const UNKNOWN_CLASS = 'unknown';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Evidence kinds a bundle may carry. Exported so callers and diagnostics can enumerate
|
|
47
|
+
* what the classifier understands instead of guessing at field names.
|
|
48
|
+
*/
|
|
49
|
+
export const EVIDENCE_KINDS = Object.freeze([
|
|
50
|
+
'hook-telemetry',
|
|
51
|
+
'hook-active',
|
|
52
|
+
'process-result',
|
|
53
|
+
'index-error',
|
|
54
|
+
'lock-outcome',
|
|
55
|
+
'context-capacity',
|
|
56
|
+
'stream-idle',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// Hook-runner outcomes that PROVE the runner ended the child (TASK-018 taxonomy). These
|
|
60
|
+
// are kill verdicts, so they name a lane; `ok` / `exit-code` are verdicts about the hook's
|
|
61
|
+
// own decision and prove nothing about liveness.
|
|
62
|
+
const KILL_OUTCOMES = new Set(['timeout', 'output-overflow', 'budget-exhausted']);
|
|
63
|
+
|
|
64
|
+
// A leak only exists AFTER the runner's OWN escalation attempt, and only if something is
|
|
65
|
+
// still alive. `failureKind: 'deadline'` is the runner's kill verdict — the deadline timer
|
|
66
|
+
// armed the TERM → KILL sequence, so survivors beside it really are an escapee (H03).
|
|
67
|
+
//
|
|
68
|
+
// `signal` is explicitly NOT escalation evidence: the runner documents it as "the child
|
|
69
|
+
// died from a signal this runner did not send" (hook-process.mjs), which is an unprompted
|
|
70
|
+
// EXTERNAL kill. Survivors beside an external signal may be unrelated processes, and naming
|
|
71
|
+
// a lane off it would fabricate a TERM → KILL diagnosis the evidence cannot support. Such a
|
|
72
|
+
// bundle is `unknown`, never `process-tree-leak`.
|
|
73
|
+
const RUNNER_ESCALATION_KINDS = new Set(['deadline']);
|
|
74
|
+
|
|
75
|
+
const UNKNOWN_CHECK = 'Collect hook telemetry, process-exit evidence, or an index/lock/context measurement for this stall — none of the current evidence proves a lane.';
|
|
76
|
+
|
|
77
|
+
const NEXT_CHECK = Object.freeze({
|
|
78
|
+
'process-tree-leak': 'Re-run the failing command with the process-tree runner and list the surviving PIDs: a process group survived its TERM→KILL escalation (check for a child that ignores or detaches from SIGTERM).',
|
|
79
|
+
'hook-overrun': 'Inspect the cited hook telemetry row: the hook was killed at its deadline or overflowed its output cap. Measure that hook alone and reduce its work below its budget.',
|
|
80
|
+
'index-deadline': 'Re-run discovery with a bounded deadline and inspect the recorded phase: git enumeration or the filesystem fallback consumed the whole budget.',
|
|
81
|
+
'lock-contention': 'Inspect the lock holder state for the named target: the acquisition budget expired or was aborted before the critical section ran (fail-closed, no mutation happened).',
|
|
82
|
+
'context-capacity': 'Re-check the negotiated context capacity against the estimate: the session reached its advisory cap, so compaction must run before more context is added.',
|
|
83
|
+
'gateway-stream-idle': 'Watch the stream idle window: no hook was active while the stream went silent, so treat this as an upstream/gateway stall and verify CLAUDE_STREAM_IDLE_TIMEOUT_MS and the non-streaming fallback.',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
function isObject(value) {
|
|
87
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isPositiveNumber(value) {
|
|
91
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resultFor(cls, confidence, evidenceIds, recommendedNextCheck) {
|
|
95
|
+
return { class: cls, confidence, evidenceIds, recommendedNextCheck };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function unknown(evidenceIds = []) {
|
|
99
|
+
return resultFor(UNKNOWN_CLASS, 'none', evidenceIds, UNKNOWN_CHECK);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── per-lane evidence evaluators ────────────────────────────────────────────
|
|
103
|
+
// Each returns { evidenceIds, confidence } when the evidence PROVES the lane, else null.
|
|
104
|
+
|
|
105
|
+
function detectProcessTreeLeak(bundle) {
|
|
106
|
+
const processResult = bundle.processResult;
|
|
107
|
+
if (!isObject(processResult)) return null;
|
|
108
|
+
const survivors = processResult.survivors;
|
|
109
|
+
// `survivors` must be an explicit, non-empty array: a missing field is unknown
|
|
110
|
+
// evidence (the caller never probed), and an empty array is a clean reap.
|
|
111
|
+
if (!Array.isArray(survivors) || survivors.length === 0) return null;
|
|
112
|
+
// Survivors alone proves nothing — the runner must have escalated. `signal` is an
|
|
113
|
+
// external kill, so it stays `unknown` rather than claiming a leak.
|
|
114
|
+
if (!RUNNER_ESCALATION_KINDS.has(processResult.failureKind)) return null;
|
|
115
|
+
return { evidenceIds: ['process-result'], confidence: 'high' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function detectHookOverrun(bundle) {
|
|
119
|
+
const rows = bundle.hookTelemetry;
|
|
120
|
+
if (!Array.isArray(rows)) return null;
|
|
121
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
122
|
+
const row = rows[i];
|
|
123
|
+
if (isObject(row) && KILL_OUTCOMES.has(row.outcome)) {
|
|
124
|
+
// Cite only the failing row — the row index is the evidence locator.
|
|
125
|
+
return { evidenceIds: [`hook-telemetry[${i}]`], confidence: 'high' };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function detectIndexDeadline(bundle) {
|
|
132
|
+
const indexError = bundle.indexError;
|
|
133
|
+
if (!isObject(indexError)) return null;
|
|
134
|
+
// Only the typed liveness failure is proof; a message alone could be anything.
|
|
135
|
+
const typed = indexError.name === 'IndexDiscoveryTimeoutError'
|
|
136
|
+
|| indexError.code === 'INDEX_DISCOVERY_TIMEOUT';
|
|
137
|
+
if (!typed) return null;
|
|
138
|
+
return { evidenceIds: ['index-error'], confidence: 'high' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function detectLockContention(bundle) {
|
|
142
|
+
const lockOutcome = bundle.lockOutcome;
|
|
143
|
+
if (!isObject(lockOutcome) || lockOutcome.ok !== false) return null;
|
|
144
|
+
// TASK-028's typed envelope: the callback never ran, so no mutation was lost.
|
|
145
|
+
if (lockOutcome.reason !== 'busy' && lockOutcome.reason !== 'aborted') return null;
|
|
146
|
+
return { evidenceIds: ['lock-outcome'], confidence: 'high' };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function detectContextCapacity(bundle) {
|
|
150
|
+
const context = bundle.context;
|
|
151
|
+
if (!isObject(context)) return null;
|
|
152
|
+
const capTokens = context.capTokens;
|
|
153
|
+
const estimatedTokens = context.estimatedTokens;
|
|
154
|
+
if (!isPositiveNumber(capTokens) || !isPositiveNumber(estimatedTokens)) return null;
|
|
155
|
+
if (estimatedTokens < capTokens) return null;
|
|
156
|
+
return { evidenceIds: ['context-capacity'], confidence: 'high' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function detectGatewayStreamIdle(bundle) {
|
|
160
|
+
const stream = bundle.stream;
|
|
161
|
+
if (!isObject(stream)) return null;
|
|
162
|
+
const idleMs = stream.idleMs;
|
|
163
|
+
const idleTimeoutMs = stream.idleTimeoutMs;
|
|
164
|
+
// Both numbers are required: without the negotiated timeout there is nothing to
|
|
165
|
+
// compare against, and an idle duration alone proves no stall.
|
|
166
|
+
if (!isPositiveNumber(idleMs) || !isPositiveNumber(idleTimeoutMs)) return null;
|
|
167
|
+
if (idleMs < idleTimeoutMs) return null;
|
|
168
|
+
// A hook was active while the stream was silent → the silence may be UKit's, so the
|
|
169
|
+
// upstream lane is removed entirely. Never blame the gateway over hook evidence.
|
|
170
|
+
// `null`/`undefined` both mean "no hook was active" — only a real value withholds it.
|
|
171
|
+
if (bundle.hookActive !== null && bundle.hookActive !== undefined) return null;
|
|
172
|
+
return { evidenceIds: ['stream-idle'], confidence: 'high' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Precedence: the most specific, evidence-backed lane wins. The upstream lane is LAST —
|
|
176
|
+
// it is the only lane whose proof is the ABSENCE of other evidence, so it may only be
|
|
177
|
+
// reached once every hook/index/lock/context claim had its chance.
|
|
178
|
+
const LANE_PRECEDENCE = Object.freeze([
|
|
179
|
+
['process-tree-leak', detectProcessTreeLeak],
|
|
180
|
+
['hook-overrun', detectHookOverrun],
|
|
181
|
+
['index-deadline', detectIndexDeadline],
|
|
182
|
+
['lock-contention', detectLockContention],
|
|
183
|
+
['context-capacity', detectContextCapacity],
|
|
184
|
+
['gateway-stream-idle', detectGatewayStreamIdle],
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
// Evidence that was considered but is NOT sufficient to name a lane. Reported on the
|
|
188
|
+
// `unknown` result so a caller sees what was looked at, never just "nothing".
|
|
189
|
+
function inconclusiveEvidenceIds(bundle) {
|
|
190
|
+
const ids = [];
|
|
191
|
+
if (Array.isArray(bundle.hookTelemetry) && bundle.hookTelemetry.length > 0) {
|
|
192
|
+
ids.push('hook-telemetry');
|
|
193
|
+
}
|
|
194
|
+
if (isObject(bundle.hookActive)) ids.push('hook-active');
|
|
195
|
+
if (isObject(bundle.stream)) ids.push('stream-idle');
|
|
196
|
+
if (isObject(bundle.processResult)) ids.push('process-result');
|
|
197
|
+
if (isObject(bundle.indexError)) ids.push('index-error');
|
|
198
|
+
if (isObject(bundle.lockOutcome)) ids.push('lock-outcome');
|
|
199
|
+
if (isObject(bundle.context)) ids.push('context-capacity');
|
|
200
|
+
return ids;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Classify ONE stall from an evidence bundle.
|
|
205
|
+
*
|
|
206
|
+
* @param {object} [bundle] evidence collected by a scenario or a live diagnostic:
|
|
207
|
+
* `hookTelemetry` — redacted telemetry rows (TASK-019 schema; `outcome` is the
|
|
208
|
+
* runner's taxonomy, `hook` names the emitter).
|
|
209
|
+
* `hookActive` — `{hook, activeMs}` when a hook held the hot path. Presence alone
|
|
210
|
+
* removes the upstream lane.
|
|
211
|
+
* `processResult` — `{failureKind, signal, code, elapsedMs, survivors}` from the
|
|
212
|
+
* process-tree runner. `survivors` is the escalation probe.
|
|
213
|
+
* `indexError` — the thrown error (or its shape) from index discovery.
|
|
214
|
+
* `lockOutcome` — the typed `{ok:false, reason:'busy'|'aborted', waitedMs}` envelope.
|
|
215
|
+
* `context` — `{capTokens, estimatedTokens, ...}` from capacity negotiation.
|
|
216
|
+
* `stream` — `{idleMs, idleTimeoutMs, ...}` from stream timing.
|
|
217
|
+
* @returns {{ class: string, confidence: 'high'|'none', evidenceIds: string[],
|
|
218
|
+
* recommendedNextCheck: string }} exactly four fields; `class` is either a
|
|
219
|
+
* member of HANG_CLASSES or UNKNOWN_CLASS.
|
|
220
|
+
*/
|
|
221
|
+
export function classifyHang(bundle = {}) {
|
|
222
|
+
if (!isObject(bundle)) return unknown();
|
|
223
|
+
|
|
224
|
+
for (const [lane, detect] of LANE_PRECEDENCE) {
|
|
225
|
+
const evidence = detect(bundle);
|
|
226
|
+
if (evidence) return resultFor(lane, evidence.confidence, evidence.evidenceIds, NEXT_CHECK[lane]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return unknown(inconclusiveEvidenceIds(bundle));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* C19 finding → lane map. Every finding this cycle addressed has a lane whose evidence a
|
|
234
|
+
* scenario in `tests/liveness/hangScenarios.test.js` produces, so no finding is left
|
|
235
|
+
* without a classifier answer. Exposed as data (not prose) so the liveness suite can fail
|
|
236
|
+
* when coverage regresses.
|
|
237
|
+
*/
|
|
238
|
+
export const C19_LANE_MAP = Object.freeze([
|
|
239
|
+
Object.freeze({ findings: Object.freeze(['H01', 'H02', 'H05', 'H08', 'H21']), lane: 'hook-overrun' }),
|
|
240
|
+
Object.freeze({ findings: Object.freeze(['H03']), lane: 'process-tree-leak' }),
|
|
241
|
+
Object.freeze({ findings: Object.freeze(['H09', 'H10', 'H11', 'H12', 'H13', 'H14', 'H15']), lane: 'index-deadline' }),
|
|
242
|
+
Object.freeze({ findings: Object.freeze(['H16', 'H17', 'H18', 'H19', 'H20']), lane: 'lock-contention' }),
|
|
243
|
+
Object.freeze({ findings: Object.freeze(['H04', 'H22']), lane: 'context-capacity' }),
|
|
244
|
+
Object.freeze({ findings: Object.freeze(['H23', 'H24']), lane: 'hook-overrun' }),
|
|
245
|
+
Object.freeze({ findings: Object.freeze(['H25']), lane: 'gateway-stream-idle' }),
|
|
246
|
+
]);
|