@ngockhoale/ukit 2.7.3 → 2.7.5

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,72 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.7.5 - 2026-09-20
6
+
7
+ Hook performance work — cycle C40 (TASK-001..007): `.mjs` module steps,
8
+ fail-closed registry, and a hook budget guard.
9
+
10
+ - `hook-chain-runner` supports `"<path>.mjs[:N]"` module steps: the arg is
11
+ imported in-proc and its `runHook(ctx)` is invoked with
12
+ `{payload, payloadText, projectRoot, env, deadlineMs, signal}` — no child
13
+ process spawn on the hot path. A throw reports `error`, a
14
+ `deadlineMs`/`AbortSignal` breach reports `timeout` + `killed`; infra
15
+ failures exit 2 when the step is fail-closed, else 1. Every step appends a
16
+ `recordHookTiming` telemetry row under its `<name>.mjs` hook name, and
17
+ `UKIT_HOOK_DEADLINE_MS` is scrubbed from `process.env`/`ctx.env` around the
18
+ import so a stale host deadline cannot leak into module steps.
19
+ - Fail-closed registry extended: `sensitive-data-guard.mjs` and
20
+ `block-dangerous.mjs` join `FAIL_CLOSED_SCRIPTS`, so module-step verdicts
21
+ keep the same fail-closed posture as the `.sh` wrappers they replaced.
22
+ - `sensitive-data-guard`, `block-dangerous`, and `record-execution` ported to
23
+ dual-mode modules: each `.mjs` exports `runHook(ctx)` for chain execution
24
+ plus an `isDirectRun()` CLI path for standalone/debug invocation;
25
+ `hookFailClosed` is exported per module (`true` for the two gates, `false`
26
+ for the telemetry sink). The `.sh` files remain as thin wrappers — staging,
27
+ salvage glue (`UKIT_SALVAGED_*` env), telemetry, then a single
28
+ `node <module>.mjs` call — with byte-identical verdicts verified against the
29
+ pre-port implementations. `record-execution.mjs` also exposes
30
+ `setLedgerMessageEmitter` on `execution-ledger.mjs` so in-proc runs route
31
+ the `noteSweepFailure` systemMessage through the emitter instead of fd 1.
32
+ - Settings + bridge wiring: `settings.json` chains and the omp bridge
33
+ `HOOK_EVENT_MAP` now point at the `.mjs` steps; the bridge's
34
+ `TIMEOUT_STAYS_CLOSED` / payload-stays-closed checks cover
35
+ `block-dangerous.mjs` so verdicts stay identical after the swap.
36
+ - Hook budget guard: `tests/hooks/hookChainBudget.test.js` enforces ≤8 chain
37
+ steps per group, requires a `:N` suffix on every `.sh|mjs` arg, and pins
38
+ outer `timeout` ≥ Σ(:N) + 10s — adding or removing a hook without updating
39
+ the outer fails loudly. The same suite asserts the settings↔bridge mirror
40
+ 1:1 for every event/matcher lane. The contract is documented under
41
+ `UKIT_INTERNALS.md → Hook budget`.
42
+
43
+ ## 2.7.4 - 2026-09-20
44
+
45
+ Deferred hook findings + residual risks — cycle C39 (TASK-001..005).
46
+
47
+ - `session-episode.sh` held-stdin stall fixed twice over: gate-first
48
+ ordering — a pre-staging node probe evaluates
49
+ `learning.episodes.autoWrite === true`; gate off / missing / corrupt
50
+ config or absent node exits 0 immediately without touching stdin
51
+ (~2084ms → ~14–40ms). The gate-on staged-read + `ukit memory episode`
52
+ flow is unchanged. The gate probe itself is armed with an unref'd
53
+ `UKIT_HOOK_DEADLINE_MS` deadline (default 2s → exit 0, gate-off safe
54
+ default) so a wedged probe can never re-create the stall it prevents.
55
+ - SessionEnd residual spawn cost (`chain-sessionend-all`) resolved: the
56
+ background watchdog subshell in `session-episode.sh` inherited the
57
+ child's stdout/stderr pipes, blocking captured-stdio callers ~2s after
58
+ script exit. Watchdog now detaches stdio (`>/dev/null 2>&1 &`) —
59
+ residual 2050ms → ~40ms median; telemetry, per-script `:N` budgets,
60
+ and fail-open posture preserved.
61
+ - Regression guard `chainTimeoutSuffixes`: every `"<path>.sh":N` budget
62
+ suffix in template + installed `settings.json` is pinned against the
63
+ script's original standalone timeout — drift in either direction
64
+ (wrong N or missing suffix) fails the suite.
65
+ - `ukit doctor` gains a `Hook-chain checks` section
66
+ (`src/core/hookChainDoctor.js`): bounded scan of recent
67
+ `hook-latency/*.jsonl` rows reports fail-closed outcomes
68
+ (timeout/error) as an advisory warning with remedy — never blocks,
69
+ exit code untouched.
70
+
5
71
  ## 2.7.3 - 2026-09-20
6
72
 
7
73
  AI freeze audit remediation — cycle C38 (TASK-001..007).
@@ -1096,6 +1096,20 @@ items:
1096
1096
  packs:
1097
1097
  - core
1098
1098
 
1099
+ # TASK-004 (C40): advisory module the thin .sh wrapper delegates to and the
1100
+ # chain runner loads in-proc.
1101
+ - id: hook-record-execution-module
1102
+ type: hook
1103
+ sourceTemplate: .claude/hooks/record-execution.mjs
1104
+ targetPath: .claude/hooks/record-execution.mjs
1105
+ requires:
1106
+ - ukit-runtime-scripts
1107
+ mergeStrategy: overwrite_with_backup
1108
+ variables: []
1109
+ enabledByDefault: true
1110
+ packs:
1111
+ - core
1112
+
1099
1113
  - id: hook-completion-gate
1100
1114
  type: hook
1101
1115
  sourceTemplate: .claude/hooks/completion-gate.sh
@@ -1141,6 +1155,20 @@ items:
1141
1155
  packs:
1142
1156
  - core
1143
1157
 
1158
+ # TASK-003 (C40): fail-closed module the thin .sh wrapper delegates to and
1159
+ # the chain runner loads in-proc.
1160
+ - id: hook-block-dangerous-module
1161
+ type: hook
1162
+ sourceTemplate: .claude/hooks/block-dangerous.mjs
1163
+ targetPath: .claude/hooks/block-dangerous.mjs
1164
+ requires:
1165
+ - ukit-runtime-scripts
1166
+ mergeStrategy: overwrite_with_backup
1167
+ variables: []
1168
+ enabledByDefault: true
1169
+ packs:
1170
+ - core
1171
+
1144
1172
  # requires: [ukit-runtime-scripts] is forward-declared for the shared sensitive-value
1145
1173
  # scanner (consumed by the guard once TASK-039 wires it). The guard is fail-closed, so
1146
1174
  # its runtime dependency must be installed first — keeps the ordering posture explicit.
@@ -1156,6 +1184,21 @@ items:
1156
1184
  packs:
1157
1185
  - core
1158
1186
 
1187
+ # TASK-002 (C40): dual-mode module the thin .sh wrapper delegates to and the
1188
+ # chain runner loads in-proc. Requires ukit-runtime-scripts for the scanner
1189
+ # AND hook-field-salvage.mjs (both ship via that directory item).
1190
+ - id: hook-sensitive-data-guard-module
1191
+ type: hook
1192
+ sourceTemplate: .claude/hooks/sensitive-data-guard.mjs
1193
+ targetPath: .claude/hooks/sensitive-data-guard.mjs
1194
+ requires:
1195
+ - ukit-runtime-scripts
1196
+ mergeStrategy: overwrite_with_backup
1197
+ variables: []
1198
+ enabledByDefault: true
1199
+ packs:
1200
+ - core
1201
+
1159
1202
  - id: hook-handoff-model-guard
1160
1203
  type: hook
1161
1204
  sourceTemplate: .claude/hooks/handoff-model-guard.sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.7.3",
3
+ "version": "2.7.5",
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
+ }