@ngockhoale/ukit 2.2.10 → 2.2.12

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,57 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.2.12 - 2026-09-04
6
+
7
+ `ukit install` and `ukit update` now clean up broken Claude Code hook registrations automatically.
8
+
9
+ A hook whose stdout contains a `hookSpecificOutput` object without the required `hookEventName`
10
+ field fails Claude Code's schema validation on **every** tool call, surfacing as a repeating
11
+ `PreToolUse:<Tool> hook error — Hook JSON output validation failed`. Such a hook has no effect
12
+ either way, since its output is discarded — it produces nothing but noise.
13
+
14
+ New `src/core/repairBrokenHooks.js` detects that shape generically (not keyed to any one vendor)
15
+ across user settings, project settings, and `settings.local.json`, expanding
16
+ `$CLAUDE_PROJECT_DIR`/`$HOME`/`~` in hook commands and also catching hooks inlined into
17
+ `settings.json` instead of a script file. Offending registrations are removed after a
18
+ `.ukit-backup` restore point is written next to each settings file. Detection matches only real
19
+ key assignments, so prose mentions of the field in a hook's own comments or docstrings never
20
+ trigger a removal.
21
+
22
+ Wired into the install pipeline; since `ukit update` re-runs `ukit install`, both commands are
23
+ covered with no new flags or commands to learn — teammates still only need `ukit install`.
24
+
25
+ Scope is deliberate: UKit removes the *registration* only. An unregistered hook script never
26
+ executes, so that is sufficient, and UKit does not delete the script file or uninstall the tool
27
+ that wrote it. When the owning tool can be identified from the hook source, the install report
28
+ names it and notes that it may re-register on activation, leaving permanent removal as a human
29
+ decision.
30
+
31
+ Known real-world instance: VSCode extension `tjcg.auto-accept-claude-code` v0.5.0 hardcodes two
32
+ such hook templates and rewrites its hook file on activation.
33
+
34
+ ## 2.2.11 - 2026-08-29
35
+
36
+ Same-day follow-up to 2.2.10. The two premises that release shipped as unverified were checked
37
+ against omp's own installed TypeScript source (`@oh-my-pi/pi-coding-agent` v18.0.10, not a live
38
+ session — omp still was not run) and both hold:
39
+
40
+ - **`steer` after a compaction is accepted, not dropped.** `session-maintenance.ts` fires
41
+ `session_compact` only after `agent.replaceMessages()`/`rebaseAfterCompaction()` already ran, so
42
+ the session is idle at that point, and `agent-session.ts`'s `sendCustomMessage()` non-streaming
43
+ branch appends the message to the live context regardless of `deliverAs`.
44
+ - **`display: true` does trigger a visible re-render.** `extension-ui-controller.ts`'s
45
+ `applyCustomMessageDisplay()` calls `rebuildChatFromMessages()` for the non-streaming case (the
46
+ streaming case renders via the `message_end` event instead).
47
+
48
+ `templates/.omp/hooks/pre/ukit-bridge.js`'s two `TODO(verify)` comments (PLAN.md §3 D3, D4) are
49
+ updated to record this — from "unverified, additive, worst-case-unchanged" to "source-confirmed,
50
+ one rung below a live repro." No runtime behaviour changes; both delivery paths (`steer` +
51
+ `nextTurn`, and the `finalNotice` fallback) stay in place as defense in depth either way. A stale
52
+ local `.omp/hooks/pre/ukit-bridge.js` install artifact in this repo — untracked, regenerated by
53
+ `ukit install`, and out of sync with `templates/.omp/` since before 2.2.10 shipped — was also found
54
+ and resynced; that file is never part of the published package.
55
+
5
56
  ## 2.2.10 - 2026-08-29
6
57
 
7
58
  User report: long omp (Oh My Pi) sessions go idle roughly 15 minutes into a task — no output, no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.2.10",
3
+ "version": "2.2.12",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
1
  import { buildPathConfig } from '../../core/paths.js';
2
2
  import { runInstallPipeline } from '../../core/runInstallPipeline.js';
3
+ import { formatRepairReport } from '../../core/repairBrokenHooks.js';
3
4
  import { buildCodeIndex } from '../../index/buildIndex.js';
4
5
  import { installIndexRefreshHooks } from '../../index/gitHooks.js';
5
6
  import fs from 'node:fs/promises';
@@ -238,6 +239,10 @@ export async function runInstall({ packageRoot, projectRoot, packageVersion, arg
238
239
  .join(', ');
239
240
  console.log(`[UKit] Providers: ${providerStatus}, all=${result.providerContext.allSupported}`);
240
241
 
242
+ for (const line of formatRepairReport(result.hookRepair ?? { removals: [], files: [] })) {
243
+ console.log(`[UKit] ${line}`);
244
+ }
245
+
241
246
  const docsLabels = [
242
247
  'docs/PROJECT.md',
243
248
  'docs/MEMORY.md',
@@ -0,0 +1,240 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ // Claude Code rejects any hook whose stdout carries a `hookSpecificOutput` object
6
+ // without `hookEventName` inside it, and surfaces it as a per-tool-call
7
+ // "Hook JSON output validation failed" error. A hook in that state never has any
8
+ // effect - its output is discarded - so removing its registration only removes noise.
9
+ //
10
+ // Detection is deliberately generic rather than keyed to one known-bad extension:
11
+ // any tool that ships this shape produces the same failure.
12
+
13
+ // Only real key assignments count. Prose mentions of the field inside docstrings
14
+ // and comments are common in hook SDKs and must not trigger a removal.
15
+ const HOOK_OUTPUT_KEY = /["']hookSpecificOutput["']\s*\]?\s*[:=]/g;
16
+
17
+ // How far past the key to look for the sibling field. Generous enough for a
18
+ // formatted object literal, short enough not to reach an unrelated later block.
19
+ const SIBLING_SCAN_WINDOW = 400;
20
+
21
+ // Paths in a hook command string, e.g. "$CLAUDE_PROJECT_DIR/.claude/hooks/x.sh".
22
+ const SCRIPT_PATH = /["']?((?:\/|~|\$\{?[A-Z_]+\}?\/)[^"'\s]+\.(?:sh|ps1|py|js|mjs|cjs|bash|zsh))["']?/g;
23
+
24
+ // e.g. "# Auto-Accept Extension: PreToolUse hook" -> "Auto-Accept Extension"
25
+ const OWNER_HINT = /^[#/\s]*([A-Z][\w-]*(?:\s+[\w-]+)*\s+Extension)\b/m;
26
+
27
+ /**
28
+ * True when `source` assigns hookSpecificOutput without a nearby hookEventName.
29
+ */
30
+ export function hasBrokenHookShape(source) {
31
+ if (typeof source !== 'string') {
32
+ return false;
33
+ }
34
+
35
+ HOOK_OUTPUT_KEY.lastIndex = 0;
36
+ let match = HOOK_OUTPUT_KEY.exec(source);
37
+ while (match) {
38
+ const window = source.slice(match.index, match.index + SIBLING_SCAN_WINDOW);
39
+ if (!window.includes('hookEventName')) {
40
+ HOOK_OUTPUT_KEY.lastIndex = 0;
41
+ return true;
42
+ }
43
+ match = HOOK_OUTPUT_KEY.exec(source);
44
+ }
45
+
46
+ return false;
47
+ }
48
+
49
+ function expandPath(rawPath, { projectRoot, homeDir }) {
50
+ return rawPath
51
+ .replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, projectRoot)
52
+ .replace(/\$\{?HOME-?\}?/g, homeDir)
53
+ .replace(/^~/, homeDir);
54
+ }
55
+
56
+ function extractScriptPaths(command, { projectRoot, homeDir }) {
57
+ const paths = [];
58
+ SCRIPT_PATH.lastIndex = 0;
59
+ let match = SCRIPT_PATH.exec(command);
60
+ while (match) {
61
+ paths.push(expandPath(match[1], { projectRoot, homeDir }));
62
+ match = SCRIPT_PATH.exec(command);
63
+ }
64
+ return paths;
65
+ }
66
+
67
+ async function readFileIfExists(filePath) {
68
+ try {
69
+ return await fs.readFile(filePath, 'utf8');
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Inspect one registered hook command. Returns null when it is fine, or
77
+ * { source, ownerHint } describing where the broken shape was found.
78
+ */
79
+ async function inspectHookCommand(command, { projectRoot, homeDir }) {
80
+ if (typeof command !== 'string' || !command) {
81
+ return null;
82
+ }
83
+
84
+ for (const scriptPath of extractScriptPaths(command, { projectRoot, homeDir })) {
85
+ const contents = await readFileIfExists(scriptPath);
86
+ if (contents !== null && hasBrokenHookShape(contents)) {
87
+ return { source: scriptPath, ownerHint: OWNER_HINT.exec(contents)?.[1] ?? null };
88
+ }
89
+ }
90
+
91
+ // Some tools inline the whole hook body into settings.json instead of a script.
92
+ if (hasBrokenHookShape(command)) {
93
+ return { source: '<inline command>', ownerHint: null };
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Drop broken hook entries from one parsed settings object.
101
+ * Mutates `settings` and returns the removals found.
102
+ */
103
+ async function stripBrokenHooks(settings, { projectRoot, homeDir }) {
104
+ const removals = [];
105
+ const events = settings?.hooks;
106
+ if (!events || typeof events !== 'object' || Array.isArray(events)) {
107
+ return removals;
108
+ }
109
+
110
+ for (const [event, groups] of Object.entries(events)) {
111
+ if (!Array.isArray(groups)) {
112
+ continue;
113
+ }
114
+
115
+ const keptGroups = [];
116
+ for (const group of groups) {
117
+ if (!group || typeof group !== 'object') {
118
+ keptGroups.push(group);
119
+ continue;
120
+ }
121
+
122
+ const hooks = Array.isArray(group.hooks) ? group.hooks : null;
123
+ if (!hooks) {
124
+ keptGroups.push(group);
125
+ continue;
126
+ }
127
+
128
+ const keptHooks = [];
129
+ for (const hook of hooks) {
130
+ const finding = await inspectHookCommand(hook?.command, { projectRoot, homeDir });
131
+ if (finding) {
132
+ removals.push({ event, matcher: group.matcher ?? '', ...finding });
133
+ } else {
134
+ keptHooks.push(hook);
135
+ }
136
+ }
137
+
138
+ // A group that was already empty stays as-is; only drop groups we emptied.
139
+ if (keptHooks.length === 0 && hooks.length > 0) {
140
+ continue;
141
+ }
142
+
143
+ group.hooks = keptHooks;
144
+ keptGroups.push(group);
145
+ }
146
+
147
+ if (keptGroups.length > 0) {
148
+ events[event] = keptGroups;
149
+ } else {
150
+ delete events[event];
151
+ }
152
+ }
153
+
154
+ return removals;
155
+ }
156
+
157
+ function backupPathFor(settingsPath) {
158
+ return `${settingsPath}.ukit-backup`;
159
+ }
160
+
161
+ /**
162
+ * Remove Claude Code hook registrations that would fail schema validation on every
163
+ * tool call, from the user-level and project-level settings files.
164
+ *
165
+ * Registration removal alone is enough: an unregistered script is never executed.
166
+ * The script file itself is left on disk untouched - it belongs to whichever tool
167
+ * wrote it, and deleting another tool's files is not UKit's call.
168
+ *
169
+ * @returns {Promise<{removals: Array, files: string[]}>}
170
+ */
171
+ export async function repairBrokenHooks({ projectRoot, homeDir = os.homedir() } = {}) {
172
+ const candidates = [
173
+ path.join(homeDir, '.claude', 'settings.json'),
174
+ path.join(projectRoot, '.claude', 'settings.json'),
175
+ path.join(projectRoot, '.claude', 'settings.local.json'),
176
+ ];
177
+
178
+ const removals = [];
179
+ const files = [];
180
+
181
+ for (const settingsPath of candidates) {
182
+ const raw = await readFileIfExists(settingsPath);
183
+ if (raw === null) {
184
+ continue;
185
+ }
186
+
187
+ let settings;
188
+ try {
189
+ settings = JSON.parse(raw);
190
+ } catch {
191
+ // Malformed settings are not ours to rewrite.
192
+ continue;
193
+ }
194
+
195
+ const found = await stripBrokenHooks(settings, { projectRoot, homeDir });
196
+ if (found.length === 0) {
197
+ continue;
198
+ }
199
+
200
+ // Keep a restore point before touching a file the user did not ask us to edit.
201
+ await fs.writeFile(backupPathFor(settingsPath), raw, 'utf8');
202
+ await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
203
+
204
+ files.push(settingsPath);
205
+ removals.push(...found.map((entry) => ({ ...entry, settingsPath })));
206
+ }
207
+
208
+ return { removals, files };
209
+ }
210
+
211
+ /**
212
+ * Human-readable lines for the install/update report. Empty array when nothing was repaired.
213
+ */
214
+ export function formatRepairReport({ removals, files }) {
215
+ if (!removals || removals.length === 0) {
216
+ return [];
217
+ }
218
+
219
+ const lines = [
220
+ `Removed ${removals.length} broken Claude Code hook registration(s).`,
221
+ 'They emitted hookSpecificOutput without hookEventName, so every tool call failed',
222
+ 'schema validation and the hook never actually ran.',
223
+ ];
224
+
225
+ for (const removal of removals) {
226
+ const owner = removal.ownerHint ? ` (${removal.ownerHint})` : '';
227
+ lines.push(` - ${removal.event}: ${removal.source}${owner}`);
228
+ }
229
+
230
+ const owners = [...new Set(removals.map((entry) => entry.ownerHint).filter(Boolean))];
231
+ if (owners.length > 0) {
232
+ lines.push(
233
+ `Warning: ${owners.join(', ')} rewrites its hook on activation, so this can come back.`,
234
+ 'Uninstall that extension to stop it for good - UKit will not remove it for you.',
235
+ );
236
+ }
237
+
238
+ lines.push(`Backup: ${files.map((file) => `${file}.ukit-backup`).join(', ')}`);
239
+ return lines;
240
+ }
@@ -12,6 +12,7 @@ import { summarizeDiff, toDiffRows } from './report.js';
12
12
  import { writeInstallMetadata } from './metadata.js';
13
13
  import { cleanupLegacyPaths, migrateLegacyRuntimeRoot } from './migrateLegacy.js';
14
14
  import { ensureGitignore } from './ensureGitignore.js';
15
+ import { repairBrokenHooks } from './repairBrokenHooks.js';
15
16
  import { cleanupEmptyParents, readJsonIfExists, removeFileOrLinkOnly, resolveProjectRelativePath } from './fileOps.js';
16
17
 
17
18
  const AUTO_PRUNE_OBSOLETE_PREFIXES = [
@@ -316,6 +317,13 @@ export async function runInstallPipeline({
316
317
  }
317
318
 
318
319
  await ensureGitignore(pathConfig.projectRoot);
320
+
321
+ // A hook that emits hookSpecificOutput without hookEventName fails Claude Code's
322
+ // schema check on every tool call, so it only ever produces noise. Teammates are
323
+ // not expected to know that - `ukit install` (and `ukit update`, which re-runs
324
+ // install) clears it for them. Backups are written next to each settings file.
325
+ const hookRepair = await repairBrokenHooks({ projectRoot: pathConfig.projectRoot });
326
+
319
327
  await pruneObsoleteManagedPaths({
320
328
  installMetaData,
321
329
  projectRoot: pathConfig.projectRoot,
@@ -350,6 +358,7 @@ export async function runInstallPipeline({
350
358
  summary,
351
359
  rows: toDiffRows(diffResults),
352
360
  writes,
361
+ hookRepair,
353
362
  };
354
363
  }
355
364
 
@@ -361,5 +370,6 @@ export async function runInstallPipeline({
361
370
  summary: plannedSummary,
362
371
  rows: toDiffRows(diffResults),
363
372
  writes: [],
373
+ hookRepair: { removals: [], files: [] },
364
374
  };
365
375
  }
@@ -470,8 +470,12 @@ export async function runSessionCompact(pi, event, { projectRoot, context: exten
470
470
  const metadata = runtimeMetadata(event, extensionContext);
471
471
  const payload = buildHookPayload('SessionStart', { ...metadata, source: 'compact' });
472
472
  const result = await runScriptChain(pi, HOOK_EVENT_MAP.session_start, payload, { projectRoot });
473
- // TODO(verify) PLAN.md §3 D3: whether omp still accepts a 'steer' message after a compaction
474
- // is unverified. If it drops steer, the 'nextTurn' copy below preserves prior behavior.
473
+ // VERIFIED (2026-08-29, source read of omp v18.0.10 pi-coding-agent/src) PLAN.md §3 D3:
474
+ // session_compact fires only after agent.replaceMessages()/rebaseAfterCompaction() already
475
+ // ran (session-maintenance.ts), i.e. the session is idle here, and agent-session.ts's
476
+ // sendCustomMessage() non-streaming branch appends the message to the live context
477
+ // regardless of deliverAs — so 'steer' is accepted right after a compaction. Not a live
478
+ // repro, so the 'nextTurn' copy below stays as defense in depth.
475
479
  sendContext(pi, result.context, 'steer');
476
480
  sendContext(pi, result.context, 'nextTurn');
477
481
  return undefined;
@@ -499,9 +503,12 @@ export async function runSessionStop(
499
503
  `[UKit] Stopping with unfinished work: ${evaluation.missingEvidence.join(', ')}.`,
500
504
  evaluation.reason,
501
505
  ].filter(Boolean).join(' ');
502
- // TODO(verify) PLAN.md §3 D4: whether omp honours display:true on a sendMessage payload
503
- // is unverified (omp is not installed). If display is ignored, the finalNotice
504
- // continuation below (the model's own message) is the channel guaranteed to render.
506
+ // VERIFIED (2026-08-29, source read of omp v18.0.10 pi-coding-agent/src) PLAN.md §3 D4:
507
+ // display:true on a non-streaming sendMessage payload triggers
508
+ // extension-ui-controller.ts's applyCustomMessageDisplay() -> rebuildChatFromMessages(),
509
+ // a real immediate TUI re-render (the streaming case renders via the message_end event
510
+ // instead). Not a live repro, so the finalNotice continuation below (the model's own
511
+ // message) stays as a second guaranteed channel.
505
512
  sendContext(pi, [notice], 'nextTurn', { display: true });
506
513
  }
507
514
  return undefined;