@ngockhoale/ukit 2.3.9 → 2.3.11
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 +87 -0
- package/package.json +1 -1
- package/src/core/fileOps.js +61 -7
- package/src/index/taskRouting.js +35 -2
- package/templates/.claude/hooks/auto-allow-bash.sh +44 -7
- package/templates/.claude/hooks/auto-prune-bash.sh +44 -7
- package/templates/.claude/hooks/context-hardcap-gate.sh +18 -5
- package/templates/.claude/hooks/handoff-resume.sh +10 -1
- package/templates/.claude/hooks/reset-compact-pressure.sh +47 -7
- package/templates/.claude/hooks/skill-router.sh +213 -44
- package/templates/.claude/hooks/verification-guard.sh +55 -20
- package/templates/.claude/ukit/index/route-task.mjs +36 -3
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +50 -4
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +339 -62
- package/templates/.claude/ukit/runtime/token-utils.mjs +69 -6
- package/templates/.omp/hooks/pre/ukit-bridge.js +68 -16
|
@@ -82,17 +82,49 @@ function sleep(ms) {
|
|
|
82
82
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
function isPidAlive(pid) {
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 0);
|
|
88
|
+
return true;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
// EPERM: the process exists but belongs to another user — still alive.
|
|
91
|
+
return error?.code === 'EPERM';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readLockOwner(lockPath) {
|
|
96
|
+
try {
|
|
97
|
+
const raw = JSON.parse(await fs.readFile(path.join(lockPath, 'owner'), 'utf8'));
|
|
98
|
+
const pid = Number(raw?.pid);
|
|
99
|
+
return Number.isInteger(pid) && pid > 0
|
|
100
|
+
? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
|
|
101
|
+
: null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// In-process holder registry: same-pid holders are parallel async flows whose liveness a
|
|
108
|
+
// pid probe cannot prove, so the module tracks them itself.
|
|
109
|
+
const inProcessLockHolders = new Map();
|
|
110
|
+
|
|
85
111
|
/**
|
|
86
112
|
* Serialize read-modify-write mutations of a shared state file — across processes
|
|
87
113
|
* (hook invocations run as separate node processes) and across concurrent async
|
|
88
114
|
* flows in one process (parallel subagents). The lock is a directory created next
|
|
89
115
|
* to the target file: `mkdir` is atomic, so exactly one caller can create it.
|
|
90
|
-
*
|
|
116
|
+
* Ownership is recorded in an `owner` file inside the lock dir: stale reclaim first
|
|
117
|
+
* proves the recorded holder is gone (dead pid, or no in-process holder for our own
|
|
118
|
+
* pid — no owner file means a pre-token holder and keeps the legacy mtime-only
|
|
119
|
+
* reclaim), so a slow-but-alive holder on a crawling disk is waited out, not stolen.
|
|
120
|
+
* Release only removes a dir this acquisition still owns, so a reclaimed-then-
|
|
121
|
+
* re-acquired lock is never deleted out from under its successor.
|
|
91
122
|
* Liveness wins over strictness: if the lock cannot be acquired within maxWaitMs
|
|
92
123
|
* the callback runs anyway (the pre-lock behaviour) — these state files are
|
|
93
124
|
* advisory caches, and losing an update beats freezing a hook mid-flight.
|
|
94
125
|
* Protocol-compatible with src/core/fileOps.js withFileLock (same `<file>.lock`
|
|
95
|
-
* path), so CLI processes and hook processes serialize
|
|
126
|
+
* path and owner-file format), so CLI processes and hook processes serialize
|
|
127
|
+
* against each other.
|
|
96
128
|
* @param {string} filePath - state file the mutation targets (lock lives beside it)
|
|
97
129
|
* @param {() => Promise<*>} fn - critical section; its result is returned
|
|
98
130
|
* @returns {Promise<*>} whatever fn resolves with
|
|
@@ -100,24 +132,48 @@ function sleep(ms) {
|
|
|
100
132
|
export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
|
|
101
133
|
const lockPath = `${filePath}.lock`;
|
|
102
134
|
const startedAt = Date.now();
|
|
135
|
+
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
|
103
136
|
let locked = false;
|
|
137
|
+
let ownerStamped = false;
|
|
104
138
|
|
|
105
139
|
while (!locked) {
|
|
106
140
|
try {
|
|
107
141
|
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
108
142
|
await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
|
|
109
143
|
locked = true;
|
|
144
|
+
inProcessLockHolders.set(lockPath, ownerToken);
|
|
145
|
+
try {
|
|
146
|
+
await fs.writeFile(
|
|
147
|
+
path.join(lockPath, 'owner'),
|
|
148
|
+
`${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
|
|
149
|
+
'utf8',
|
|
150
|
+
);
|
|
151
|
+
ownerStamped = true;
|
|
152
|
+
} catch {
|
|
153
|
+
ownerStamped = false; // unverifiable release skips removal; stale reclaim cleans up
|
|
154
|
+
}
|
|
110
155
|
break;
|
|
111
156
|
} catch (error) {
|
|
112
157
|
if (error?.code !== 'EEXIST') throw error;
|
|
113
158
|
}
|
|
114
159
|
|
|
115
|
-
// Someone holds the lock. Reclaim it when
|
|
160
|
+
// Someone holds the lock. Reclaim it only when the holder is provably gone.
|
|
116
161
|
try {
|
|
117
162
|
const stat = await fs.stat(lockPath);
|
|
118
163
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
119
|
-
await
|
|
120
|
-
|
|
164
|
+
const owner = await readLockOwner(lockPath);
|
|
165
|
+
const liveInProcess = inProcessLockHolders.has(lockPath);
|
|
166
|
+
// Stealing a live holder reintroduces the exact interleaved-write race this
|
|
167
|
+
// lock exists to prevent, and the stolen holder's release then deleted the
|
|
168
|
+
// successor's lock. Only a dead pid (or a leaked same-pid dir with no live
|
|
169
|
+
// registered flow) may be reclaimed.
|
|
170
|
+
const reclaimable = !owner || owner.pid === process.pid
|
|
171
|
+
? !liveInProcess
|
|
172
|
+
: !isPidAlive(owner.pid);
|
|
173
|
+
if (reclaimable) {
|
|
174
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
175
|
+
continue; // the slot is free now — retry immediately
|
|
176
|
+
}
|
|
121
177
|
}
|
|
122
178
|
} catch {
|
|
123
179
|
continue; // lock vanished between mkdir and stat — retry immediately
|
|
@@ -132,7 +188,14 @@ export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxW
|
|
|
132
188
|
} finally {
|
|
133
189
|
if (locked) {
|
|
134
190
|
try {
|
|
135
|
-
|
|
191
|
+
// Remove the lock only if THIS acquisition still owns it: after a stale reclaim
|
|
192
|
+
// another holder may already own the dir, and deleting it would unlock their
|
|
193
|
+
// critical section for a third waiter.
|
|
194
|
+
const current = ownerStamped ? await readLockOwner(lockPath) : null;
|
|
195
|
+
if (current && current.token === ownerToken) {
|
|
196
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
if (inProcessLockHolders.get(lockPath) === ownerToken) inProcessLockHolders.delete(lockPath);
|
|
136
199
|
} catch {
|
|
137
200
|
// best-effort release; a stale lock is reclaimed by the next waiter
|
|
138
201
|
}
|
|
@@ -11,8 +11,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
12
|
import {
|
|
13
13
|
evaluateCompletion,
|
|
14
|
+
evidencePromptKey,
|
|
14
15
|
incrementContinuation,
|
|
15
16
|
markNotified,
|
|
17
|
+
noteStopProgress,
|
|
16
18
|
readExecutionLedger,
|
|
17
19
|
readRouteState,
|
|
18
20
|
recordExecutionReceipt,
|
|
@@ -107,6 +109,15 @@ function classifyFailure(scriptName) {
|
|
|
107
109
|
return FAIL_CLOSED_SCRIPTS.has(scriptName) ? 'closed' : 'open';
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
// A child killed by its per-script budget produced NO verdict — that is an infrastructure
|
|
113
|
+
// event (slow disk, lock contention, process storm), not a safety decision. Routing it
|
|
114
|
+
// into the fail-closed branch below blocked every Edit/Write (and Read's sensitive-data
|
|
115
|
+
// guard) for as long as the machine was slow — the exact mid-run freeze class this bridge
|
|
116
|
+
// must not create. One deliberate exception: block-dangerous.sh is the gate the user
|
|
117
|
+
// explicitly required to never fail open (destructive-command protection), so a Bash chain
|
|
118
|
+
// whose dangerous-command check timed out stays blocked with the honest reason.
|
|
119
|
+
const TIMEOUT_STAYS_CLOSED = new Set(['block-dangerous.sh']);
|
|
120
|
+
|
|
110
121
|
function runtimeMetadata(event = {}, context = {}) {
|
|
111
122
|
const sessionManager = context?.sessionManager;
|
|
112
123
|
return {
|
|
@@ -160,12 +171,30 @@ function parseStructuredDecision(stdout) {
|
|
|
160
171
|
|
|
161
172
|
function translateExecResult(scriptName, execResult) {
|
|
162
173
|
const killed = Boolean(execResult?.killed);
|
|
163
|
-
const code = killed ? 1 : (execResult?.code ?? 0);
|
|
164
174
|
const stdout = execResult?.stdout ?? '';
|
|
165
175
|
const stderr = killed
|
|
166
176
|
? (execResult?.stderr || `${scriptName} was killed before it completed`)
|
|
167
177
|
: (execResult?.stderr ?? '');
|
|
168
178
|
|
|
179
|
+
if (killed) {
|
|
180
|
+
if (TIMEOUT_STAYS_CLOSED.has(scriptName)) {
|
|
181
|
+
return {
|
|
182
|
+
block: true,
|
|
183
|
+
reason: `${scriptName} exceeded its hook budget and was killed, so UKit could not verify the command is safe — the call stays blocked. Retry once; if this repeats, the machine is too slow for the guard to finish.`,
|
|
184
|
+
stdout,
|
|
185
|
+
stderr,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
block: false,
|
|
190
|
+
warning: `${scriptName} exceeded its hook budget and was killed — treated as "could not verify", not as a block (a timeout is an infrastructure event, not a verdict): ${stderr}`,
|
|
191
|
+
stdout,
|
|
192
|
+
stderr,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const code = execResult?.code ?? 0;
|
|
197
|
+
|
|
169
198
|
if (code === 0) {
|
|
170
199
|
return { block: false, stdout, stderr };
|
|
171
200
|
}
|
|
@@ -437,16 +466,18 @@ function sendContext(pi, context, deliverAs, { display = false } = {}) {
|
|
|
437
466
|
|
|
438
467
|
export async function runToolCall(pi, event, { projectRoot, context: extensionContext = {} }) {
|
|
439
468
|
const rawToolName = event.toolName ?? event.tool;
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
469
|
+
const mappedToolName = mapToolName(rawToolName);
|
|
470
|
+
// An unmapped tool that LOOKS like a file mutation must not sail past the guards — but
|
|
471
|
+
// hard-blocking it froze the run whenever omp renamed or added a tool, because the model
|
|
472
|
+
// cannot add a host adapter mapping mid-run and the block had no recovery path. Run the
|
|
473
|
+
// Edit guard chain on the normalized input instead: the guards key off
|
|
474
|
+
// tool_input.file_path and the mutation fields, not the host tool name. The mapping gap
|
|
475
|
+
// still surfaces as a visible warning so it gets fixed.
|
|
476
|
+
const unmappedMutation = !mappedToolName && looksMutationCapable(event.input);
|
|
477
|
+
const toolName = mappedToolName || (unmappedMutation ? 'Edit' : null);
|
|
478
|
+
const priorContext = unmappedMutation
|
|
479
|
+
? [`[UKit] unmapped mutation-capable tool "${rawToolName}" was normalized to Edit so the guard chain still ran; add a mapping for it in ukit-bridge.js.`]
|
|
480
|
+
: [];
|
|
450
481
|
|
|
451
482
|
const metadata = runtimeMetadata(event, extensionContext);
|
|
452
483
|
const payload = buildHookPayload('PreToolUse', {
|
|
@@ -462,8 +493,9 @@ export async function runToolCall(pi, event, { projectRoot, context: extensionCo
|
|
|
462
493
|
projectRoot,
|
|
463
494
|
failClosedOnTransportError,
|
|
464
495
|
});
|
|
465
|
-
|
|
466
|
-
|
|
496
|
+
const merged = { ...result, context: [...priorContext, ...(result.context || [])] };
|
|
497
|
+
if (!merged.block) sendContext(pi, merged.context, 'steer');
|
|
498
|
+
return { ...merged, toolName };
|
|
467
499
|
}
|
|
468
500
|
|
|
469
501
|
export async function runToolResult(pi, event, { projectRoot, context: extensionContext = {} }) {
|
|
@@ -581,9 +613,12 @@ export async function runSessionStop(
|
|
|
581
613
|
const evaluation = evaluateCompletion({ state, ledger });
|
|
582
614
|
if (!evaluation.continue) {
|
|
583
615
|
if (evaluation.capped) pi.logger?.warn?.(`[UKit] ${evaluation.reason}`);
|
|
584
|
-
|
|
616
|
+
const missingEvidence = Array.isArray(evaluation.missingEvidence) ? evaluation.missingEvidence : [];
|
|
617
|
+
// The visible-blocker exit (verification loop) carries no missingEvidence but must still
|
|
618
|
+
// reach the user — a silent release is indistinguishable from a stall.
|
|
619
|
+
if (missingEvidence.length > 0 || evaluation.notify) {
|
|
585
620
|
const notice = [
|
|
586
|
-
`[UKit] Stopping with unfinished work: ${
|
|
621
|
+
missingEvidence.length > 0 ? `[UKit] Stopping with unfinished work: ${missingEvidence.join(', ')}.` : '[UKit] Stopping automatic recovery.',
|
|
587
622
|
evaluation.reason,
|
|
588
623
|
].filter(Boolean).join(' ');
|
|
589
624
|
// VERIFIED (2026-08-29, source read of omp v18.0.10 pi-coding-agent/src) PLAN.md §3 D4:
|
|
@@ -598,9 +633,26 @@ export async function runSessionStop(
|
|
|
598
633
|
}
|
|
599
634
|
|
|
600
635
|
if (suppliedLedger === undefined) {
|
|
636
|
+
// Claude Code flags reentrant stops with stop_hook_active and the ledger CLI releases on
|
|
637
|
+
// them; omp's session_stop carries no such field. Detect the equivalent from the ledger:
|
|
638
|
+
// when the recovery turn produced no new receipts since the stop that blocked it,
|
|
639
|
+
// blocking again would only burn another turn in a self-sustaining loop — release with a
|
|
640
|
+
// visible reason instead. Vibecode autonomy keeps pushing (same as the CLI valve).
|
|
641
|
+
let reentrantStop = false;
|
|
642
|
+
try {
|
|
643
|
+
reentrantStop = (await noteStopProgress(projectRoot, payload)).reentrant === true;
|
|
644
|
+
} catch {
|
|
645
|
+
reentrantStop = false;
|
|
646
|
+
}
|
|
647
|
+
if (reentrantStop && state?.routeSummary?.autonomyLevel !== 'vibecode') {
|
|
648
|
+
const notice = `UKit stopped automatic recovery after one continuation: ${evaluation.reason}`;
|
|
649
|
+
pi.logger?.warn?.(`[UKit] ${notice}`);
|
|
650
|
+
sendContext(pi, [notice], 'nextTurn', { display: true });
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
601
653
|
try {
|
|
602
654
|
if (evaluation.finalNotice) await markNotified(projectRoot, payload, ledger);
|
|
603
|
-
else await incrementContinuation(projectRoot, payload, ledger, state?.requestKey || null);
|
|
655
|
+
else await incrementContinuation(projectRoot, payload, ledger, state?.requestKey || null, evidencePromptKey(state));
|
|
604
656
|
} catch (error) {
|
|
605
657
|
pi.logger?.warn?.(`[UKit] continuation bookkeeping failed open: ${error?.message || error}`);
|
|
606
658
|
}
|