@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
|
@@ -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
|
-
|
|
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
|