@ngockhoale/ukit 2.4.0 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/package.json +1 -1
  3. package/scripts/index/refresh-index.mjs +10 -5
  4. package/src/cli/commands/doctor.js +59 -2
  5. package/src/core/gatewayProbe.js +143 -15
  6. package/src/core/gatewayResilienceEnv.js +136 -7
  7. package/src/index/buildIndex.js +74 -24
  8. package/templates/.claude/hooks/auto-allow-bash.sh +24 -1
  9. package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
  10. package/templates/.claude/hooks/block-dangerous.sh +20 -1
  11. package/templates/.claude/hooks/completion-gate.sh +19 -2
  12. package/templates/.claude/hooks/compress-output.sh +17 -2
  13. package/templates/.claude/hooks/context-hardcap-gate.sh +22 -3
  14. package/templates/.claude/hooks/context-window-guard.sh +84 -61
  15. package/templates/.claude/hooks/handoff-model-guard.sh +24 -3
  16. package/templates/.claude/hooks/handoff-resume.sh +21 -3
  17. package/templates/.claude/hooks/post-edit-verify.sh +19 -2
  18. package/templates/.claude/hooks/pre-edit-backup.sh +19 -2
  19. package/templates/.claude/hooks/protect-files.sh +20 -1
  20. package/templates/.claude/hooks/record-execution.sh +20 -2
  21. package/templates/.claude/hooks/reinject-context.sh +1 -1
  22. package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
  23. package/templates/.claude/hooks/sensitive-data-guard.sh +64 -17
  24. package/templates/.claude/hooks/skill-router.sh +44 -5
  25. package/templates/.claude/hooks/stale-spec-guard.sh +21 -2
  26. package/templates/.claude/hooks/task-watchdog.sh +25 -7
  27. package/templates/.claude/hooks/verification-guard.sh +54 -19
  28. package/templates/.claude/hooks/vision-router.sh +96 -12
  29. package/templates/.claude/ukit/index/lib/index-core.mjs +78 -16
  30. package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
  31. package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
  32. package/templates/.claude/ukit/index/refresh-index.mjs +10 -5
  33. package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
  34. package/templates/.claude/ukit/runtime/execution-ledger.mjs +8 -0
  35. package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
  36. package/templates/.claude/ukit/runtime/hook-input.sh +60 -0
  37. package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
  38. package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
  39. package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
@@ -2,7 +2,7 @@
2
2
  import path from 'node:path';
3
3
  import {
4
4
  buildCodeIndex,
5
- isIndexStale,
5
+ inspectIndexStaleness,
6
6
  DEFAULT_INDEX_CACHE_MAX_AGE_MS,
7
7
  getIndexArtifactGeneratedAt,
8
8
  } from './lib/index-core.mjs';
@@ -14,9 +14,10 @@ const changedFiles = changedArg ? changedArg.split(',').map((x) => x.trim()).fil
14
14
  const force = args.includes('--force');
15
15
 
16
16
  const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir });
17
- const stale = force
18
- ? true
19
- : await isIndexStale({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS, generatedAtMs: lastRefreshMs });
17
+ const staleness = force
18
+ ? null
19
+ : await inspectIndexStaleness({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS, generatedAtMs: lastRefreshMs });
20
+ const stale = force || staleness.stale;
20
21
 
21
22
  if (!stale) {
22
23
  console.log('[ukit:index:refresh] skipped (cache fresh)');
@@ -28,7 +29,11 @@ if (!stale) {
28
29
  }
29
30
  console.log(`root: ${rootDir}`);
30
31
  } else {
31
- const summary = await buildCodeIndex({ rootDir });
32
+ // Reuse the staleness check's discovery snapshot: one enumeration per refresh.
33
+ const summary = await buildCodeIndex({
34
+ rootDir,
35
+ discoverySnapshot: staleness ? staleness.snapshot : null,
36
+ });
32
37
 
33
38
  console.log('[ukit:index:refresh] completed');
34
39
  if (lastRefreshMs !== null) {
@@ -13,6 +13,14 @@ import {
13
13
  summarizeSnippet,
14
14
  } from '../runtime/safe-patch-core.mjs';
15
15
 
16
+ // Hook-context self-deadline (2.4.1 orphan-leak class): the stale-spec-guard hook passes
17
+ // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
18
+ // budget. Non-hook usage never sets it and is never self-killed.
19
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
20
+ if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
21
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
22
+ }
23
+
16
24
  function getToolName(payload = {}) {
17
25
  return String(payload.tool_name || payload.tool || payload.name || '').trim();
18
26
  }
@@ -7,6 +7,14 @@ import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { withFileLock } from './token-utils.mjs';
9
9
 
10
+ // Hook-context self-deadline (2.4.1 orphan-leak class): wrapper hooks pass
11
+ // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the
12
+ // hook budget. CLI usage never sets it and is never self-killed.
13
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
14
+ if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
15
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
16
+ }
17
+
10
18
  const LEDGER_VERSION = 1;
11
19
  const RESUME_INTENT_VERSION = 1;
12
20
  const RESUME_INTENT_TTL_MS = 30 * 60 * 1000;
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // hook-input.mjs — canonical bounded stdin transport for UKit hooks (H01).
3
+ //
4
+ // readHookInput({ maxBytes, overflowPolicy, stdin }) ->
5
+ // Promise<{ text, truncated, tempPath }>
6
+ //
7
+ // Policies: 'truncate' | 'temp-file' | 'refuse' (throws HookInputOverflow
8
+ // after draining stdin, so producers never see EPIPE).
9
+ //
10
+ // CLI form stages stdin to --out and prints one JSON report line:
11
+ // node hook-input.mjs --max-bytes 2097152 --policy truncate --out /tmp/staged
12
+
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ import { realpathSync } from 'node:fs';
18
+
19
+ export class HookInputOverflow extends Error {
20
+ constructor(maxBytes) {
21
+ super(`hook stdin exceeds ${maxBytes} bytes`);
22
+ this.name = 'HookInputOverflow';
23
+ this.maxBytes = maxBytes;
24
+ }
25
+ }
26
+
27
+ export async function readHookInput({
28
+ maxBytes = 2 * 1024 * 1024,
29
+ overflowPolicy = 'truncate',
30
+ stdin = process.stdin,
31
+ tempRoot = os.tmpdir(),
32
+ } = {}) {
33
+ const chunks = [];
34
+ let total = 0;
35
+ let truncated = false;
36
+ let overflow = false;
37
+ for await (const chunk of stdin) {
38
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
39
+ if (truncated || overflow) continue;
40
+ if (total + buf.length > maxBytes) {
41
+ if (overflowPolicy === 'refuse') {
42
+ overflow = true;
43
+ continue;
44
+ }
45
+ const keep = maxBytes - total;
46
+ if (keep > 0) chunks.push(buf.subarray(0, keep));
47
+ truncated = true;
48
+ continue;
49
+ }
50
+ total += buf.length;
51
+ chunks.push(buf);
52
+ }
53
+ if (overflow) throw new HookInputOverflow(maxBytes);
54
+ const body = Buffer.concat(chunks);
55
+ let tempPath = null;
56
+ if (overflowPolicy === 'temp-file') {
57
+ const dir = fs.mkdtempSync(path.join(tempRoot, 'ukit-hook-in.'));
58
+ tempPath = path.join(dir, 'payload');
59
+ fs.writeFileSync(tempPath, body);
60
+ }
61
+ return { text: body.toString('utf8'), truncated, tempPath };
62
+ }
63
+
64
+ function parseArgs(argv) {
65
+ const parsed = { maxBytes: 2 * 1024 * 1024, policy: 'truncate', out: null };
66
+ for (let i = 0; i < argv.length; i += 1) {
67
+ const arg = argv[i];
68
+ if (arg === '--max-bytes') parsed.maxBytes = Number.parseInt(argv[++i], 10);
69
+ else if (arg === '--policy') parsed.policy = argv[++i];
70
+ else if (arg === '--out') parsed.out = argv[++i];
71
+ }
72
+ if (!['truncate', 'temp-file', 'refuse'].includes(parsed.policy)) {
73
+ throw new Error(`unknown overflow policy: ${parsed.policy}`);
74
+ }
75
+ if (!Number.isFinite(parsed.maxBytes) || parsed.maxBytes <= 0) {
76
+ throw new Error(`invalid --max-bytes: ${parsed.maxBytes}`);
77
+ }
78
+ return parsed;
79
+ }
80
+
81
+ async function cli(argv) {
82
+ const { maxBytes, policy, out } = parseArgs(argv);
83
+ let result;
84
+ try {
85
+ result = await readHookInput({ maxBytes, overflowPolicy: policy });
86
+ } catch (error) {
87
+ if (error instanceof HookInputOverflow) {
88
+ process.stderr.write(`${error.message} (policy: refuse)\n`);
89
+ return 2;
90
+ }
91
+ throw error;
92
+ }
93
+ if (out) fs.writeFileSync(out, result.text, 'utf8');
94
+ process.stdout.write(
95
+ `${JSON.stringify({
96
+ bytes: Buffer.byteLength(result.text),
97
+ truncated: result.truncated,
98
+ tempPath: result.tempPath,
99
+ })}\n`,
100
+ );
101
+ return 0;
102
+ }
103
+
104
+ const isMain = (() => {
105
+ try {
106
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
107
+ } catch {
108
+ return false;
109
+ }
110
+ })();
111
+
112
+ if (isMain) {
113
+ cli(process.argv.slice(2)).then(
114
+ (code) => process.exit(code),
115
+ (error) => {
116
+ process.stderr.write(`hook-input: ${error?.message ?? error}\n`);
117
+ process.exit(1);
118
+ },
119
+ );
120
+ }
@@ -0,0 +1,60 @@
1
+ # hook-input.sh — bounded stdin staging for UKit hook wrappers.
2
+ #
3
+ # H01 fix: wrappers must never materialize unbounded stdin in a shell
4
+ # variable before any deadline is armed. Source this file, stage stdin
5
+ # through ukit_stage_hook_input, then hand UKIT_INPUT_FILE (a path, never
6
+ # the payload) to Node or read a bounded view via command substitution.
7
+ #
8
+ # Usage:
9
+ # source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh"
10
+ # trap ukit_cleanup_hook_input EXIT
11
+ # ukit_stage_hook_input 2097152 truncate || exit 0
12
+ #
13
+ # Policies:
14
+ # truncate — keep at most max_bytes; UKIT_INPUT_TRUNCATED=1 on overflow
15
+ # temp-file — same bound; the staged file is the payload transport
16
+ # refuse — return 2 without staging when stdin exceeds max_bytes
17
+ #
18
+ # refuse is reserved for fail-closed security hooks (sensitive-data-guard):
19
+ # it returns 2 silently and never echoes payload bytes.
20
+
21
+ ukit_stage_hook_input() {
22
+ local max_bytes="$1" policy="${2:-truncate}"
23
+ local dir file size
24
+ dir="$(mktemp -d "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")" || return 1
25
+ file="$dir/payload"
26
+ head -c $((max_bytes + 1)) > "$file" 2>/dev/null
27
+ # Drain the remainder so the producer never sees EPIPE; discarded bytes
28
+ # are never materialized.
29
+ cat > /dev/null 2>&1 || true
30
+ size="$(wc -c < "$file" | tr -d '[:space:]')"
31
+ UKIT_INPUT_TRUNCATED=0
32
+ if [ "$size" -gt "$max_bytes" ]; then
33
+ case "$policy" in
34
+ refuse)
35
+ rm -rf "$dir"
36
+ UKIT_INPUT_FILE=""
37
+ return 2
38
+ ;;
39
+ truncate|temp-file)
40
+ truncate -s "$max_bytes" "$file" 2>/dev/null || { rm -rf "$dir"; return 1; }
41
+ UKIT_INPUT_TRUNCATED=1
42
+ ;;
43
+ *)
44
+ rm -rf "$dir"
45
+ return 1
46
+ ;;
47
+ esac
48
+ fi
49
+ UKIT_INPUT_FILE="$file"
50
+ return 0
51
+ }
52
+
53
+ ukit_cleanup_hook_input() {
54
+ if [ -n "${UKIT_INPUT_FILE:-}" ] && [ -f "$UKIT_INPUT_FILE" ]; then
55
+ rm -f "$UKIT_INPUT_FILE"
56
+ rmdir "$(dirname "$UKIT_INPUT_FILE")" 2>/dev/null || true
57
+ fi
58
+ UKIT_INPUT_FILE=""
59
+ UKIT_INPUT_TRUNCATED=0
60
+ }
@@ -16,6 +16,14 @@ import {
16
16
  } from './token-utils.mjs';
17
17
  import { updateCompactPressureFromOutput } from './compact-threshold.mjs';
18
18
 
19
+ // Hook-context self-deadline (2.4.1 orphan-leak class): the compress-output hook passes
20
+ // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
21
+ // budget. Non-hook usage never sets it and is never self-killed.
22
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
23
+ if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
24
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
25
+ }
26
+
19
27
  const ANSI_RE = /\u001b\[[0-9;]*m/g;
20
28
  const DEFAULT_MAX_OUTPUT_TOKENS = 180;
21
29
  const DEFAULT_OUTPUT_HISTORY_MAX_ENTRIES = 25;
@@ -19,6 +19,14 @@ import {
19
19
  writeThresholdCompactPlan,
20
20
  } from './compact-threshold.mjs';
21
21
 
22
+ // Hook-context self-deadline (2.4.1 orphan-leak class): the reinject-context hook passes
23
+ // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
24
+ // budget. Non-hook usage never sets it and is never self-killed.
25
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
26
+ if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
27
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
28
+ }
29
+
22
30
  const STATE_TTL_MS = 30 * 60 * 1000;
23
31
  const STOPWORDS = new Set([
24
32
  'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
@@ -0,0 +1,107 @@
1
+ // transcript-tail.mjs — bounded, reverse-safe JSONL tail reader (H04).
2
+ //
3
+ // scanTranscriptTail(path, { maxBytes, signal }) ->
4
+ // Promise<{ entries, truncated, bytesRead }>
5
+ //
6
+ // Why this exists: the context-window-guard hook used to stat/read/split up to 24 MiB of
7
+ // live transcript synchronously on every UserPromptSubmit. This module is the async,
8
+ // hard-capped replacement: seek near the end of the file, read at most `maxBytes`, skip a
9
+ // partial first line, and parse complete records only.
10
+ //
11
+ // Failure posture is FAIL-OPEN, always: concurrent growth/truncation, vanished files,
12
+ // permission errors, and aborts all RESOLVE (with whatever was read, or an empty result)
13
+ // — scanTranscriptTail never throws and never prints a stack trace. Callers are advisory
14
+ // hooks; a lost measurement must never block a prompt.
15
+
16
+ import fsp from 'node:fs/promises';
17
+
18
+ // Hard ceiling on bytes read per scan. Well under the old 24 MiB synchronous read, and
19
+ // the same order as compact-threshold.mjs's BOUNDARY_TAIL_BYTES so one number describes
20
+ // a UKit tail scan. No config knob exists for byte caps (compact.hardCapTokens is tokens),
21
+ // so this constant IS the ceiling; tests pin it below 24 MiB.
22
+ export const DEFAULT_TAIL_MAX_BYTES = 4 * 1024 * 1024;
23
+
24
+ const EMPTY_RESULT = () => ({ entries: [], truncated: false, bytesRead: 0 });
25
+
26
+ function positiveInteger(value, fallback) {
27
+ return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0
28
+ ? value
29
+ : fallback;
30
+ }
31
+
32
+ async function scanOnce(filePath, maxBytes) {
33
+ const cap = positiveInteger(maxBytes, DEFAULT_TAIL_MAX_BYTES);
34
+ const stat = await fsp.stat(filePath);
35
+ if (!stat.isFile() || stat.size <= 0) {
36
+ return EMPTY_RESULT();
37
+ }
38
+ const start = Math.max(0, stat.size - cap);
39
+ const length = Math.min(cap, stat.size);
40
+ const truncated = start > 0;
41
+ const handle = await fsp.open(filePath, 'r');
42
+ let bytesRead = 0;
43
+ let text = '';
44
+ try {
45
+ const buffer = Buffer.allocUnsafe(length);
46
+ ({ bytesRead } = await handle.read(buffer, 0, length, start));
47
+ text = buffer.toString('utf8', 0, bytesRead);
48
+ } finally {
49
+ try {
50
+ await handle.close();
51
+ } catch { /* a racing writer/truncator may have invalidated the handle already */ }
52
+ }
53
+
54
+ // A tail read almost certainly starts mid-record: drop everything before the first
55
+ // complete line. If the window holds no newline at all, nothing parseable is in it.
56
+ let payload = text;
57
+ if (truncated) {
58
+ const firstNewline = text.indexOf('\n');
59
+ if (firstNewline === -1) {
60
+ return { entries: [], truncated: true, bytesRead };
61
+ }
62
+ payload = text.slice(firstNewline + 1);
63
+ }
64
+
65
+ // Parse complete records only; partial or non-JSON lines are skipped, exactly like the
66
+ // consumers that previously split the raw text did. An unterminated final line that
67
+ // still parses (writer flushed the record, newline pending) is a complete record.
68
+ const entries = [];
69
+ for (const line of payload.split('\n')) {
70
+ if (!line) continue;
71
+ try {
72
+ entries.push(JSON.parse(line));
73
+ } catch { /* partial or non-JSON record — skip */ }
74
+ }
75
+ return { entries, truncated, bytesRead };
76
+ }
77
+
78
+ export async function scanTranscriptTail(transcriptPath, { maxBytes, signal } = {}) {
79
+ if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) {
80
+ return EMPTY_RESULT();
81
+ }
82
+ if (signal?.aborted) {
83
+ return EMPTY_RESULT();
84
+ }
85
+ // Race the scan against the abort signal so a deadline (e.g. the hook's 3 s
86
+ // self-deadline) abandons an in-flight read instead of waiting on it.
87
+ let onAbort;
88
+ const aborted = new Promise((resolve) => {
89
+ onAbort = () => resolve(EMPTY_RESULT());
90
+ try {
91
+ signal?.addEventListener('abort', onAbort, { once: true });
92
+ } catch {
93
+ resolve(EMPTY_RESULT());
94
+ }
95
+ });
96
+ try {
97
+ return await Promise.race([scanOnce(transcriptPath, maxBytes), aborted]);
98
+ } catch {
99
+ // Unreadable transcript (first prompt of a session, permissions, races, aborts).
100
+ // Nothing to measure; fail open with an empty result.
101
+ return EMPTY_RESULT();
102
+ } finally {
103
+ try {
104
+ signal?.removeEventListener('abort', onAbort);
105
+ } catch { /* signal went away between add and remove */ }
106
+ }
107
+ }