@ngockhoale/ukit 2.4.2 → 2.5.0

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 (59) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +20 -0
  3. package/manifests/platform.full.yaml +51 -112
  4. package/package.json +2 -1
  5. package/scripts/index/refresh-index.mjs +47 -22
  6. package/src/cli/commands/doctor.js +132 -2
  7. package/src/cli/commands/uninstall.js +18 -0
  8. package/src/core/applyPlan.js +17 -2
  9. package/src/core/compact/threshold.js +36 -6
  10. package/src/core/diffPlan.js +35 -0
  11. package/src/core/fileOps.js +26 -0
  12. package/src/core/projectImportant.js +430 -0
  13. package/src/core/sensitiveValueScanner.js +118 -0
  14. package/src/core/status.js +55 -1
  15. package/src/core/uninstall.js +183 -3
  16. package/src/diagnostics/classifyHang.js +246 -0
  17. package/src/index/buildIndex.js +1033 -62
  18. package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
  19. package/templates/.claude/hooks/block-dangerous.sh +31 -5
  20. package/templates/.claude/hooks/completion-gate.sh +51 -10
  21. package/templates/.claude/hooks/compress-output.sh +38 -6
  22. package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
  23. package/templates/.claude/hooks/context-window-guard.sh +128 -18
  24. package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
  25. package/templates/.claude/hooks/handoff-resume.sh +31 -5
  26. package/templates/.claude/hooks/post-edit-verify.sh +31 -5
  27. package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
  28. package/templates/.claude/hooks/project-important.sh +67 -0
  29. package/templates/.claude/hooks/protect-files.sh +31 -5
  30. package/templates/.claude/hooks/record-execution.sh +31 -5
  31. package/templates/.claude/hooks/sensitive-data-guard.sh +124 -56
  32. package/templates/.claude/hooks/skill-router.sh +31 -5
  33. package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
  34. package/templates/.claude/hooks/task-watchdog.sh +108 -123
  35. package/templates/.claude/hooks/verification-guard.sh +107 -112
  36. package/templates/.claude/hooks/vision-router.sh +49 -13
  37. package/templates/.claude/settings.json +5 -5
  38. package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
  39. package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
  40. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  41. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  42. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  43. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  44. package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
  45. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  46. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  47. package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
  48. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  49. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  51. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  52. package/templates/.claude/ukit/runtime/project-important.mjs +381 -0
  53. package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
  54. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  55. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  56. package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
  57. package/templates/.omp/hooks/pre/ukit-bridge.js +178 -61
  58. package/templates/AGENTS.md +8 -0
  59. package/templates/PROJECT_IMPORTANT.md +9 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * sensitiveValueScanner.js (TASK-036)
3
+ *
4
+ * Shared source-side extraction of the high-confidence secret *value* scanner
5
+ * that lives in templates/.claude/hooks/sensitive-data-guard.sh. One semantic
6
+ * implementation is consumed by the runtime hook mirror (TASK-038/039) and the
7
+ * PROJECT_IMPORTANT.md renderer (TASK-037).
8
+ *
9
+ * Scope is the value scanner only: vendor/API-key patterns, JWT, private-key
10
+ * block, SHA-256 exact-value allowlist matching, and the
11
+ * security.sensitiveDataGate config read. File/path classification and Bash
12
+ * command-shape logic stay in the hook.
13
+ *
14
+ * Leak-safety contract: return values carry labels only — never a secret
15
+ * value, prefix, suffix, excerpt, or hash of a matched secret.
16
+ */
17
+
18
+ import { createHash } from 'node:crypto';
19
+
20
+ function sha256(value) {
21
+ return createHash('sha256').update(value).digest('hex');
22
+ }
23
+
24
+ // --- high-confidence secret value patterns ---
25
+ // Semantically identical to TOKEN_PATTERNS in sensitive-data-guard.sh.
26
+ // AKIA/ASIA/AIza prefixes are pure base64-compatible text, so an encoded blob
27
+ // can contain a coincidental key-shaped substring — those rules must stand
28
+ // alone with no base64/base64url character on either side.
29
+ const TOKEN_PATTERNS = [
30
+ { label: 'OpenAI/Anthropic-style API key', re: /\bsk-(?:proj-|ant-|svc-|acct-|admin-)?[A-Za-z0-9_-]{20,}/g },
31
+ { label: 'AWS access key id', re: /(?<![A-Za-z0-9+/_-])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9+/_-])/g },
32
+ { label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
33
+ { label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
34
+ { label: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20,}/g },
35
+ { label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
36
+ { label: 'Google API key', re: /(?<![A-Za-z0-9+/_-])AIza[0-9A-Za-z_-]{20,}(?![A-Za-z0-9+/_-])/g },
37
+ { label: 'Stripe live key', re: /\b[srp]k_live_[A-Za-z0-9]{20,}/g },
38
+ { label: 'JWT', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
39
+ { label: 'private key block', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
40
+ ];
41
+
42
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/i;
43
+
44
+ /**
45
+ * Scan text for high-confidence secret values.
46
+ *
47
+ * @param {string} text
48
+ * @param {{ allowlistHashes?: string[], gateEnabled?: boolean }} [options]
49
+ * allowlistHashes: SHA-256 hex digests of explicitly approved exact values.
50
+ * gateEnabled: pass isSensitiveDataGateEnabled(config); when false the scan
51
+ * reports the gate's exit-0 semantics (no detection) like today's hook.
52
+ * @returns {{ hasSecret: boolean, allowed: boolean, labels: string[] }}
53
+ * labels only — never values, excerpts, or hashes.
54
+ */
55
+ export function scanText(text, options = {}) {
56
+ const { allowlistHashes = [], gateEnabled = true } = options || {};
57
+ const clean = { hasSecret: false, allowed: false, labels: [] };
58
+ if (gateEnabled === false) return clean;
59
+ if (typeof text !== 'string' || text.length === 0) return clean;
60
+
61
+ const allowed = new Set(
62
+ Array.isArray(allowlistHashes) ? allowlistHashes.filter((h) => typeof h === 'string') : [],
63
+ );
64
+
65
+ const labels = new Set();
66
+ let sawAllowlisted = false;
67
+ for (const { label, re } of TOKEN_PATTERNS) {
68
+ re.lastIndex = 0;
69
+ let match;
70
+ while ((match = re.exec(text)) !== null) {
71
+ if (allowed.has(sha256(match[0]))) {
72
+ sawAllowlisted = true;
73
+ continue;
74
+ }
75
+ labels.add(label);
76
+ }
77
+ }
78
+
79
+ if (labels.size === 0) {
80
+ return { hasSecret: false, allowed: sawAllowlisted, labels: [] };
81
+ }
82
+ return { hasSecret: true, allowed: false, labels: [...labels] };
83
+ }
84
+
85
+ /**
86
+ * Gate toggle: enabled unless security.sensitiveDataGate === false
87
+ * (mirrors the hook's explicit-false check on .ukit/storage/config.json).
88
+ *
89
+ * @param {object|null|undefined} config injected config object
90
+ * @returns {boolean}
91
+ */
92
+ export function isSensitiveDataGateEnabled(config) {
93
+ if (config && config.security && config.security.sensitiveDataGate === false) {
94
+ return false;
95
+ }
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * Load the SHA-256 exact-value allowlist from an injected config object.
101
+ * Accepts security.allowlist.values (mirror of allowlist.json) or
102
+ * security.sensitiveDataAllowlist; keeps only well-formed 64-hex digests.
103
+ *
104
+ * @param {object|null|undefined} config
105
+ * @returns {string[]} SHA-256 hex digests
106
+ */
107
+ export function loadSensitiveAllowlist(config) {
108
+ if (!config || typeof config !== 'object') return [];
109
+ const security = config.security && typeof config.security === 'object' ? config.security : {};
110
+ const candidates = [];
111
+ if (security.allowlist && Array.isArray(security.allowlist.values)) {
112
+ candidates.push(...security.allowlist.values);
113
+ }
114
+ if (Array.isArray(security.sensitiveDataAllowlist)) {
115
+ candidates.push(...security.sensitiveDataAllowlist);
116
+ }
117
+ return candidates.filter((v) => typeof v === 'string' && SHA256_HEX_RE.test(v));
118
+ }
@@ -7,6 +7,7 @@ import { buildPromptCacheStats } from './token/index.js';
7
7
  import { countMemoryItems } from './memory/store.js';
8
8
  import { buildCompactPressureState } from './compact/threshold.js';
9
9
  import { detectProjectContext } from '../context/detectProjectContext.js';
10
+ import { inspectProjectImportant, inspectProjectImportantWiring } from './projectImportant.js';
10
11
 
11
12
  function formatPrimaryAgent(agentKey) {
12
13
  if (agentKey === 'claude-code') return 'Claude Code';
@@ -151,6 +152,29 @@ export async function buildStatusReport(projectRoot) {
151
152
  const memoryCounts = await countMemoryItems(projectRoot);
152
153
  const adapters = await detectAdapterLabels(projectRoot, config.agent);
153
154
 
155
+ // TASK-044 — typed projectImportant state + conditional adapter wiring (spec §13).
156
+ const installMeta = await readStatusJson(
157
+ path.join(projectRoot, '.claude', 'ukit', '.ukit', 'install.json'),
158
+ );
159
+ const trackedPaths = Array.isArray(installMeta?.files)
160
+ ? installMeta.files
161
+ .map((entry) => (typeof entry === 'string' ? entry : entry?.p))
162
+ .filter((entry) => typeof entry === 'string')
163
+ : [];
164
+ const piInspection = await inspectProjectImportant({ projectRoot, config });
165
+ const piWiring = await inspectProjectImportantWiring(projectRoot, { trackedPaths });
166
+ const projectImportant = {
167
+ state: piInspection.state,
168
+ codePointCount: piInspection.codePointCount,
169
+ oversized: piInspection.oversized,
170
+ wiring: {
171
+ claude: piWiring.claude.wired,
172
+ omp: piWiring.omp.installed ? piWiring.omp.wired : undefined,
173
+ codex: piWiring.codex.installed ? piWiring.codex.wired : undefined,
174
+ opencode: piWiring.opencode.installed ? piWiring.opencode.wired : undefined,
175
+ },
176
+ };
177
+
154
178
  return {
155
179
  version: config.version,
156
180
  projectName: projectContext.project.name,
@@ -176,14 +200,43 @@ export async function buildStatusReport(projectRoot) {
176
200
  ),
177
201
  defaultModel: config.router.defaultModel,
178
202
  advisorEnabled: Boolean(config.router.advisorEnabled),
203
+ projectImportant,
179
204
  };
180
205
  }
181
206
 
207
+ const PROJECT_IMPORTANT_STATE_LABELS = {
208
+ ready: (pi) => `ready / ${pi.codePointCount.toLocaleString('en-US')} of 6,000 code points`,
209
+ oversized: () => 'oversized / >6,000 code points / injects first 6,000',
210
+ empty: () => 'empty / not injected',
211
+ missing: () => 'missing / not injected',
212
+ 'invalid-utf8': () => 'invalid UTF-8 / not injected',
213
+ 'unsafe-control': () => 'unsupported control characters / not injected',
214
+ 'secret-blocked': () => 'secret-blocked / not injected',
215
+ 'unsafe-type': () => 'unsafe symlink / not injected',
216
+ unreadable: () => 'unreadable / not injected',
217
+ };
218
+
219
+ function formatProjectImportantRules(pi) {
220
+ const formatter = PROJECT_IMPORTANT_STATE_LABELS[pi?.state] ?? (() => 'unreadable / not injected');
221
+ return formatter(pi);
222
+ }
223
+
224
+ function formatProjectImportantWiring(pi) {
225
+ const mark = (wired) => (wired ? '✓' : '✗');
226
+ const parts = [`Claude runtime ${mark(pi.wiring.claude)}`];
227
+ if (pi.wiring.omp !== undefined) parts.push(`omp runtime ${mark(pi.wiring.omp)}`);
228
+ if (pi.wiring.codex !== undefined) parts.push(`Codex fallback ${mark(pi.wiring.codex)}`);
229
+ if (pi.wiring.opencode !== undefined) parts.push(`OpenCode fallback ${mark(pi.wiring.opencode)}`);
230
+ return parts.join(' / ');
231
+ }
232
+
182
233
  export function formatStatusReport(report) {
183
234
  return [
184
235
  `UKit v${report.version} — Status`,
185
236
  '─────────────────────',
186
237
  `${padLabel('Project')} ${report.projectName}`,
238
+ `${padLabel('Project rules')} ${formatProjectImportantRules(report.projectImportant)}`,
239
+ `${padLabel('Rule wiring')} ${formatProjectImportantWiring(report.projectImportant)}`,
187
240
  `${padLabel('Adapters')} ${report.adapters.join(', ')}`,
188
241
  `${padLabel('Memory items')} ${report.memoryCounts.projectCount} project / ${report.memoryCounts.sessionCount} session / ${report.memoryCounts.userCount} user`,
189
242
  `${padLabel('Last compact')} ${report.lastCompact}`,
@@ -191,7 +244,8 @@ export function formatStatusReport(report) {
191
244
  `${padLabel('Compact lanes')} ${report.compactLanes}`,
192
245
  `${padLabel('Ctx pressure')} ${report.contextPressure}`,
193
246
  `${padLabel('Threshold cmp')} ${report.thresholdCompact}`,
194
- `${padLabel('Prompt cache')} ${report.promptCache}`,
247
+ `${padLabel('Local p-cache')} ${report.promptCache}`,
248
+ `${padLabel('Provider cache')} unknown (host telemetry unavailable)`,
195
249
  `${padLabel('Cache lanes')} ${report.cacheLanes}`,
196
250
  `${padLabel('Output comp.')} ${report.outputCompression}`,
197
251
  `${padLabel('Output lanes')} ${report.outputLanes}`,
@@ -2,12 +2,154 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import {
4
4
  cleanupEmptyParents,
5
+ copyFileRawExclusive,
5
6
  readJsonIfExists,
6
7
  removeLinkOrDir,
7
8
  removeLinkOnly,
8
9
  resolveProjectRelativePath,
9
10
  } from './fileOps.js';
10
11
  import { removeGitignoreBlock } from './ensureGitignore.js';
12
+ import { PROJECT_IMPORTANT_FILENAME } from './projectImportant.js';
13
+
14
+ // PROJECT_IMPORTANT.md is owner content, never UKit-managed. It must NEVER appear
15
+ // in ALLOWED_UNINSTALL_PREFIXES and is never deleted/renamed/chmodded/rewritten/
16
+ // tracked/followed. Before any destructive uninstall step a regular source gets a
17
+ // raw-byte backup beside it at the project root (never under .claude/.ukit/.codex/
18
+ // .omp — those may be deleted): `<name>.ukit-backup`, then `.ukit-backup.1`, `.2`, …
19
+ // via bounded exclusive-create collision walk. See docs/PROJECT_IMPORTANT_SPEC.md §15.
20
+ const IMPORTANT_BACKUP_SUFFIX = '.ukit-backup';
21
+ const IMPORTANT_BACKUP_COLLISION_LIMIT = 100;
22
+
23
+ function importantBackupCandidateName(index) {
24
+ return index === 0
25
+ ? `${PROJECT_IMPORTANT_FILENAME}${IMPORTANT_BACKUP_SUFFIX}`
26
+ : `${PROJECT_IMPORTANT_FILENAME}${IMPORTANT_BACKUP_SUFFIX}.${index}`;
27
+ }
28
+
29
+ // Read-only inspection used by BOTH dry-run and real runs: resolves which backup
30
+ // candidate would be used without creating anything. An existing backup whose
31
+ // bytes already match the source is reused; a different-bytes or non-regular
32
+ // occupant pushes the walk to the next suffix. Nothing is ever overwritten.
33
+ async function planImportantBackup(projectRoot) {
34
+ const sourcePath = path.join(projectRoot, PROJECT_IMPORTANT_FILENAME);
35
+ const plan = {
36
+ sourcePath,
37
+ sourceBytes: null,
38
+ sourceExists: false,
39
+ sourceIsRegular: false,
40
+ wouldBackup: false,
41
+ backupPath: null,
42
+ reused: false,
43
+ exhausted: false,
44
+ preservedPaths: [],
45
+ backupWarnings: [],
46
+ };
47
+
48
+ let stat;
49
+ try {
50
+ stat = await fs.lstat(sourcePath); // lstat: never follow a source symlink
51
+ } catch {
52
+ return plan; // no source — nothing to preserve or back up
53
+ }
54
+ plan.sourceExists = true;
55
+ plan.preservedPaths.push(sourcePath);
56
+
57
+ if (!stat.isFile() || stat.isSymbolicLink()) {
58
+ // Symlink/non-regular source: never followed or copied — preserved in place.
59
+ plan.backupWarnings.push(
60
+ `[UKit] ${PROJECT_IMPORTANT_FILENAME} is not a regular file; preserved in place without a backup. Run \`ukit doctor\`.`,
61
+ );
62
+ return plan;
63
+ }
64
+ plan.sourceIsRegular = true;
65
+ plan.sourceBytes = await fs.readFile(sourcePath); // raw bytes, no decode
66
+
67
+ for (let index = 0; index <= IMPORTANT_BACKUP_COLLISION_LIMIT; index += 1) {
68
+ const candidatePath = path.join(projectRoot, importantBackupCandidateName(index));
69
+ let candidateStat;
70
+ try {
71
+ candidateStat = await fs.lstat(candidatePath);
72
+ } catch {
73
+ candidateStat = null;
74
+ }
75
+ if (!candidateStat) {
76
+ plan.wouldBackup = true;
77
+ plan.backupPath = candidatePath;
78
+ return plan;
79
+ }
80
+ if (candidateStat.isFile() && !candidateStat.isSymbolicLink()) {
81
+ const existingBytes = await fs.readFile(candidatePath).catch(() => null);
82
+ if (existingBytes && existingBytes.equals(plan.sourceBytes)) {
83
+ plan.backupPath = candidatePath;
84
+ plan.reused = true;
85
+ return plan;
86
+ }
87
+ }
88
+ // different bytes or non-regular — try next suffix
89
+ }
90
+
91
+ plan.exhausted = true;
92
+ return plan;
93
+ }
94
+
95
+ // Real-run backup: byte-identical raw copy via exclusive create (COPYFILE_EXCL —
96
+ // never overwrites). EEXIST means the slot filled between plan and copy; the walk
97
+ // re-checks it (equal bytes → reuse, else next suffix). Any other failure cleans
98
+ // up a possible partial copy and ABORTS uninstall before managed deletion.
99
+ async function createImportantBackup(projectRoot, sourcePath, sourceBytes) {
100
+ for (let index = 0; index <= IMPORTANT_BACKUP_COLLISION_LIMIT; index += 1) {
101
+ const candidatePath = path.join(projectRoot, importantBackupCandidateName(index));
102
+ let candidateStat;
103
+ try {
104
+ candidateStat = await fs.lstat(candidatePath);
105
+ } catch {
106
+ candidateStat = null;
107
+ }
108
+ if (candidateStat) {
109
+ if (candidateStat.isFile() && !candidateStat.isSymbolicLink()) {
110
+ const existingBytes = await fs.readFile(candidatePath).catch(() => null);
111
+ if (existingBytes && existingBytes.equals(sourceBytes)) {
112
+ return candidatePath; // reused — already byte-identical
113
+ }
114
+ }
115
+ continue;
116
+ }
117
+ try {
118
+ await copyFileRawExclusive(sourcePath, candidatePath); // preserves mode where supported
119
+ return candidatePath;
120
+ } catch (error) {
121
+ if (error?.code === 'EEXIST') {
122
+ // Lost the race — the slot now exists. Reuse it when its bytes match the
123
+ // source; otherwise advance to the next suffix.
124
+ try {
125
+ const racedStat = await fs.lstat(candidatePath);
126
+ if (racedStat.isFile() && !racedStat.isSymbolicLink()) {
127
+ const racedBytes = await fs.readFile(candidatePath).catch(() => null);
128
+ if (racedBytes && racedBytes.equals(sourceBytes)) {
129
+ return candidatePath;
130
+ }
131
+ }
132
+ } catch {
133
+ // vanished again — either way, move on
134
+ }
135
+ continue;
136
+ }
137
+ try {
138
+ await fs.rm(candidatePath, { force: true }); // clean partial backup
139
+ } catch {
140
+ // best-effort cleanup only
141
+ }
142
+ // Error carries no source content — only the failing operation.
143
+ throw new Error(
144
+ `[UKit] Uninstall aborted: could not back up ${PROJECT_IMPORTANT_FILENAME} (${error?.code || error?.message}). Managed paths were not removed.`,
145
+ { cause: error },
146
+ );
147
+ }
148
+ }
149
+ throw new Error(
150
+ `[UKit] Uninstall aborted: no free backup name for ${PROJECT_IMPORTANT_FILENAME} after ${IMPORTANT_BACKUP_COLLISION_LIMIT} collisions. Managed paths were not removed.`,
151
+ );
152
+ }
11
153
 
12
154
  // Use lstat (not access) so broken symlinks are also detected as existing.
13
155
  async function pathExistsLstat(targetPath) {
@@ -254,13 +396,42 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
254
396
  );
255
397
  }
256
398
 
399
+ // PROJECT_IMPORTANT.md preservation — read-only plan first (spec §15). The
400
+ // source is never touched: only inspected via lstat + readFile.
401
+ const importantPlan = await planImportantBackup(projectRoot);
402
+
257
403
  if (dryRun) {
258
- // Use lstat (not access) so broken symlinks are included in the report.
404
+ // Dry run creates nothing: report the collision-resolved candidate and the
405
+ // preserved/refused state only. lstat (not access) keeps broken symlinks visible.
259
406
  const wouldRemove = [];
260
407
  for (const { abs } of safeEntries) {
261
408
  if (await pathExistsLstat(abs)) wouldRemove.push(abs);
262
409
  }
263
- return { removed: 0, attempted: allEntries.length, wasInstalled: true, wouldRemove, skippedSymlinkParents };
410
+ return {
411
+ removed: 0,
412
+ attempted: allEntries.length,
413
+ wasInstalled: true,
414
+ wouldRemove,
415
+ wouldBackup: importantPlan.wouldBackup,
416
+ backupPaths: importantPlan.backupPath ? [importantPlan.backupPath] : [],
417
+ preservedPaths: importantPlan.preservedPaths,
418
+ backupWarnings: importantPlan.backupWarnings,
419
+ skippedSymlinkParents,
420
+ };
421
+ }
422
+
423
+ // Backup runs BEFORE any destructive step. A failure here aborts uninstall —
424
+ // managed paths stay intact and the source is never modified.
425
+ const backupPaths = [];
426
+ if (importantPlan.wouldBackup) {
427
+ const backupPath = await createImportantBackup(
428
+ projectRoot,
429
+ importantPlan.sourcePath,
430
+ importantPlan.sourceBytes,
431
+ );
432
+ backupPaths.push(backupPath);
433
+ } else if (importantPlan.backupPath && importantPlan.reused) {
434
+ backupPaths.push(importantPlan.backupPath); // existing byte-identical backup
264
435
  }
265
436
 
266
437
  // Remove all paths in parallel
@@ -289,5 +460,14 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
289
460
  await cleanupEmptyParents(removedPath, projectRoot);
290
461
  }
291
462
 
292
- return { removed, attempted: allEntries.length, wasInstalled: true, skippedSymlinkParents };
463
+ return {
464
+ removed,
465
+ attempted: allEntries.length,
466
+ wasInstalled: true,
467
+ wouldBackup: importantPlan.wouldBackup,
468
+ backupPaths,
469
+ preservedPaths: importantPlan.preservedPaths,
470
+ backupWarnings: importantPlan.backupWarnings,
471
+ skippedSymlinkParents,
472
+ };
293
473
  }
@@ -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
+ ]);