@ngockhoale/ukit 2.7.3 → 2.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.7.4 - 2026-09-20
6
+
7
+ Deferred hook findings + residual risks — cycle C39 (TASK-001..005).
8
+
9
+ - `session-episode.sh` held-stdin stall fixed twice over: gate-first
10
+ ordering — a pre-staging node probe evaluates
11
+ `learning.episodes.autoWrite === true`; gate off / missing / corrupt
12
+ config or absent node exits 0 immediately without touching stdin
13
+ (~2084ms → ~14–40ms). The gate-on staged-read + `ukit memory episode`
14
+ flow is unchanged. The gate probe itself is armed with an unref'd
15
+ `UKIT_HOOK_DEADLINE_MS` deadline (default 2s → exit 0, gate-off safe
16
+ default) so a wedged probe can never re-create the stall it prevents.
17
+ - SessionEnd residual spawn cost (`chain-sessionend-all`) resolved: the
18
+ background watchdog subshell in `session-episode.sh` inherited the
19
+ child's stdout/stderr pipes, blocking captured-stdio callers ~2s after
20
+ script exit. Watchdog now detaches stdio (`>/dev/null 2>&1 &`) —
21
+ residual 2050ms → ~40ms median; telemetry, per-script `:N` budgets,
22
+ and fail-open posture preserved.
23
+ - Regression guard `chainTimeoutSuffixes`: every `"<path>.sh":N` budget
24
+ suffix in template + installed `settings.json` is pinned against the
25
+ script's original standalone timeout — drift in either direction
26
+ (wrong N or missing suffix) fails the suite.
27
+ - `ukit doctor` gains a `Hook-chain checks` section
28
+ (`src/core/hookChainDoctor.js`): bounded scan of recent
29
+ `hook-latency/*.jsonl` rows reports fail-closed outcomes
30
+ (timeout/error) as an advisory warning with remedy — never blocks,
31
+ exit code untouched.
32
+
5
33
  ## 2.7.3 - 2026-09-20
6
34
 
7
35
  AI freeze audit remediation — cycle C38 (TASK-001..007).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.7.3",
3
+ "version": "2.7.4",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,6 +25,7 @@ import {
25
25
  import { runDocContractChecks } from '../../core/docContracts.js';
26
26
  import { inspectUnattendedMode } from '../../core/unattendedDoctor.js';
27
27
  import { inspectPermissions } from '../../core/permissionDoctor.js';
28
+ import { inspectHookChainHealth } from '../../core/hookChainDoctor.js';
28
29
  import { findOpencodeArtifacts, opencodeSteerMessage } from '../../core/opencodeSteer.js';
29
30
 
30
31
  export const DOCTOR_HELP_FLAGS = new Set(['--help', '-h']);
@@ -316,6 +317,19 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
316
317
  const permissionReport = await inspectPermissions({ projectRoot, ompPath });
317
318
  printPermissionSection(permissionReport, { verbose: argv.includes('--permissions') });
318
319
 
320
+ // TASK-004 / FR-005 — hook-chain failure-taxonomy watch. Advisory only:
321
+ // warnings render as ✗ + (warning) but never enter the blocking set below.
322
+ const hookChainHealth = await inspectHookChainHealth({ projectRoot });
323
+ console.log('[UKit] Hook-chain checks:');
324
+ {
325
+ const sev = hookChainHealth.severity === 'warning' ? ' (warning)' : hookChainHealth.severity === 'info' ? ' (info)' : '';
326
+ console.log(`[UKit] ${ok(hookChainHealth.passed)} ${hookChainHealth.label}${sev}`);
327
+ if (hookChainHealth.detail) console.log(`[UKit] detail: ${hookChainHealth.detail}`);
328
+ if (hookChainHealth.remedy && (!hookChainHealth.passed || hookChainHealth.severity === 'warning')) {
329
+ console.log(`[UKit] remedy: ${hookChainHealth.remedy}`);
330
+ }
331
+ }
332
+
319
333
  if (runtimeConfigInspection.errors.length > 0) {
320
334
  console.log(`[UKit] Runtime config issues: ${runtimeConfigInspection.errors.join(' | ')}`);
321
335
  }
@@ -0,0 +1,149 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+
4
+ // TASK-004 / SPEC §FR-005 — production-facing watch over the chain-runner
5
+ // failure taxonomy (report §7 residual risk). Scans recent
6
+ // `.ukit/storage/cache/hook-latency/*.jsonl` rows for `hook-chain-runner`
7
+ // entries and reports non-`ok` outcomes by failureKind.
8
+ //
9
+ // Posture: ADVISORY ONLY. A warning never enters doctor's blocking set —
10
+ // missing/clean telemetry is a pass, non-ok outcomes surface as
11
+ // severity:'warning' so doctor.js prints ✗ + (warning) without flipping the
12
+ // exit code (same convention as permissionDoctor drift checks).
13
+
14
+ const WINDOW_MS = 24 * 60 * 60 * 1000; // last 24h of telemetry files
15
+ const MAX_FILES = 32; // newest session files by mtime
16
+ const MAX_ROWS = 5000; // total rows parsed across all files
17
+ const MAX_BYTES_PER_FILE = 512 * 1024; // tail-read bound per file
18
+ const CHAIN_HOOK = 'hook-chain-runner';
19
+
20
+ // Row outcomes that mean a fail-closed gate tripped or a child script died.
21
+ const FAILURE_OUTCOMES = new Set([
22
+ 'timeout',
23
+ 'budget-exhausted',
24
+ 'output-overflow',
25
+ 'error',
26
+ 'signal',
27
+ ]);
28
+
29
+ function telemetryDirFor(projectRoot) {
30
+ return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
31
+ }
32
+
33
+ async function recentTelemetryFiles(dir, now) {
34
+ let entries;
35
+ try {
36
+ entries = await fs.readdir(dir, { withFileTypes: true });
37
+ } catch {
38
+ return []; // dir absent — unknown, not failure
39
+ }
40
+ const candidates = [];
41
+ for (const entry of entries) {
42
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
43
+ const filePath = path.join(dir, entry.name);
44
+ try {
45
+ const stat = await fs.stat(filePath);
46
+ candidates.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size });
47
+ } catch {
48
+ // vanished between readdir/stat — skip
49
+ }
50
+ }
51
+ // Bound: only files touched inside the window, newest first, capped.
52
+ return candidates
53
+ .filter((f) => now - f.mtimeMs <= WINDOW_MS)
54
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
55
+ .slice(0, MAX_FILES);
56
+ }
57
+
58
+ async function tailLines(filePath, size) {
59
+ const handle = await fs.open(filePath, 'r');
60
+ try {
61
+ const length = Math.min(size, MAX_BYTES_PER_FILE);
62
+ const start = Math.max(0, size - length);
63
+ const buffer = Buffer.alloc(length);
64
+ await handle.read(buffer, 0, length, start);
65
+ let text = buffer.toString('utf8');
66
+ if (start > 0) {
67
+ // Drop the first (probably partial) line of a tail-read.
68
+ const nl = text.indexOf('\n');
69
+ text = nl >= 0 ? text.slice(nl + 1) : '';
70
+ }
71
+ return text.split('\n').filter((line) => line.length > 0);
72
+ } finally {
73
+ await handle.close();
74
+ }
75
+ }
76
+
77
+ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } = {}) {
78
+ const dir = telemetryDirFor(projectRoot);
79
+ const files = await recentTelemetryFiles(dir, now);
80
+
81
+ const counts = { rowsScanned: 0, filesScanned: files.length, ok: 0 };
82
+ let rowsLeft = MAX_ROWS;
83
+
84
+ for (const file of files) {
85
+ if (rowsLeft <= 0) break;
86
+ let lines;
87
+ try {
88
+ lines = await tailLines(file.filePath, file.size);
89
+ } catch {
90
+ continue; // unreadable file — skip, never fatal
91
+ }
92
+ for (const line of lines) {
93
+ if (rowsLeft <= 0) break;
94
+ let row;
95
+ try {
96
+ row = JSON.parse(line);
97
+ } catch {
98
+ continue; // corrupt line ignored — regression guard
99
+ }
100
+ if (row?.hook !== CHAIN_HOOK) continue;
101
+ rowsLeft -= 1;
102
+ counts.rowsScanned += 1;
103
+ const outcome = typeof row.outcome === 'string' && row.outcome.length > 0 ? row.outcome : 'ok';
104
+ counts[outcome] = (counts[outcome] ?? 0) + 1;
105
+ }
106
+ }
107
+
108
+ const label = 'hook-chain health';
109
+
110
+ if (counts.rowsScanned === 0) {
111
+ return {
112
+ label,
113
+ passed: true,
114
+ failed: false,
115
+ severity: 'info',
116
+ remediationClass: 'advisory',
117
+ detail: 'no telemetry — no hook-chain-runner rows in .ukit/storage/cache/hook-latency/ (unknown, not failure)',
118
+ counts,
119
+ };
120
+ }
121
+
122
+ const failureKinds = Object.keys(counts)
123
+ .filter((key) => FAILURE_OUTCOMES.has(key) && counts[key] > 0)
124
+ .sort();
125
+
126
+ if (failureKinds.length === 0) {
127
+ return {
128
+ label,
129
+ passed: true,
130
+ failed: false,
131
+ severity: 'info',
132
+ remediationClass: 'advisory',
133
+ detail: `${counts.rowsScanned} chain row(s) scanned across ${counts.filesScanned} file(s) — all outcome:ok`,
134
+ counts,
135
+ };
136
+ }
137
+
138
+ const breakdown = failureKinds.map((kind) => `${kind}=${counts[kind]}`).join(', ');
139
+ return {
140
+ label,
141
+ passed: false,
142
+ failed: true,
143
+ severity: 'warning',
144
+ remediationClass: 'advisory',
145
+ detail: `${counts.rowsScanned} chain row(s) scanned — fail-closed outcomes: ${breakdown}`,
146
+ remedy: 'Inspect recent rows in .ukit/storage/cache/hook-latency/ for the failing scriptName/failureKind and fix or widen the gate budget.',
147
+ counts,
148
+ };
149
+ }
@@ -15,6 +15,28 @@
15
15
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
16
16
  CONFIG_FILE="$PROJECT_ROOT/.ukit/storage/config.json"
17
17
 
18
+ # Gate-first fast path (TASK-001): evaluate learning.episodes.autoWrite BEFORE
19
+ # staging stdin. A producer that holds the pipe open would otherwise burn the
20
+ # full stdin-staging watchdog (measured ~2020ms) even though a gate-off run
21
+ # never inspects the payload — the same held-stdin stall TASK-235 fixed in
22
+ # project-important.sh. Missing/corrupt config or missing node resolves to 0,
23
+ # i.e. gate off → exit 0 without touching stdin.
24
+ __ukit_ep_gate="$(node -e '
25
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 2000;
26
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
27
+ const fs = require("fs");
28
+ try {
29
+ const config = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
30
+ process.stdout.write(config?.learning?.episodes?.autoWrite === true ? "1" : "0");
31
+ } catch {
32
+ process.stdout.write("0");
33
+ }
34
+ ' "$CONFIG_FILE" 2>/dev/null)" || __ukit_ep_gate="0"
35
+ if [ "$__ukit_ep_gate" != "1" ]; then
36
+ exit 0
37
+ fi
38
+ unset __ukit_ep_gate
39
+
18
40
  # Bounded stdin read (existing hook style): cap +1 byte in background so a
19
41
  # producer that never closes the pipe cannot park the session teardown.
20
42
  UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-episode-in.XXXXXX")" || exit 0
@@ -23,7 +45,10 @@ if [ -e /dev/fd/0 ]; then
23
45
  head -c 65537 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
24
46
  UKIT_HEAD_PID=$!
25
47
  # UKIT_HOOK_STAGE_MS:-2000 — bounded wait for the staged payload.
26
- ( sleep 2; kill "$UKIT_HEAD_PID" 2>/dev/null ) &
48
+ # TASK-002: detach the watchdog's inherited stdout/stderr — otherwise a
49
+ # captured-stdio caller (hook-chain-runner) sees the pipe held open ~2s
50
+ # after the script exits, inflating the SessionEnd residual to ~2000ms.
51
+ ( sleep 2; kill "$UKIT_HEAD_PID" 2>/dev/null ) >/dev/null 2>&1 &
27
52
  UKIT_WATCH_PID=$!
28
53
  wait "$UKIT_HEAD_PID" 2>/dev/null
29
54
  kill "$UKIT_WATCH_PID" 2>/dev/null