@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
@@ -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-execution-ledger-script
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-execution-ledger-script
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-compact-threshold-script
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-compact-threshold-script
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
- - id: ukit-runtime-task-watchdog-script
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/task-watchdog.mjs
1210
- targetPath: .claude/ukit/runtime/task-watchdog.mjs
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-output-compression-script
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-text-profile-script
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-text-profile-script
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-text-profile-script
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-text-profile-script
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-text-profile-script
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.2",
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 (!stale) {
28
- console.log('[index:refresh] skipped (cache fresh)');
29
- if (lastRefreshMs !== null) {
30
- console.log(`lastRefresh: ${new Date(lastRefreshMs).toISOString()}`);
31
- }
32
- if (changedFiles.length > 0) {
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
- if (changedFiles.length > 0) {
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 softThreshold = Math.max(
441
- 1,
442
- finiteNumber(config?.compact?.tokenThreshold, loadShippedCompactBudget().tokenThreshold),
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
+ ]);