@ngockhoale/ukit 2.3.8 → 2.3.10

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,96 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.3.10 - 2026-09-11
6
+
7
+ Freeze-sweep waves 7-10 (post-2.3.9): twelve verified mid-run-freeze defects fixed, each locked
8
+ by a regression reproducing the failure first. All affected template/live mirrors are byte-synced.
9
+
10
+ **P1 — continuation budget reset on every route re-key.** The router re-keys `requestKey` per
11
+ tool call (the target file is part of the key), but the Stop-gate continuation budget was keyed
12
+ on `requestKey` — the count reset to 0 on every re-key, the cap was unreachable, and omp looped
13
+ forever (no `stop_hook_active` valve). Budget identity is now the logical prompt hash
14
+ (`promptKey`) everywhere, and omp gained `stop_hook_active` parity via `noteStopProgress`.
15
+
16
+ **P1 — parallel execution receipts crashed and raced.** Same-process concurrent receipt writers
17
+ shared one temp path and crashed with ENOENT mid-hook; unlocked read-modify-write also dropped
18
+ receipts, and a missing/foreign route state could rebuild a session ledger from blank, erasing
19
+ banked evidence. Temp paths are unique, the whole receipt write runs under `withFileLock`, and
20
+ a missing route no longer blanks the ledger.
21
+
22
+ **P1 — ledger lock waits exceeded the hook child budget.** `withFileLock` waited up to 5s but
23
+ the hook chain kills children at 4s, so a contended lock got the recording hook killed and omp's
24
+ fail-closed Edit|Write chain then blocked the edit. Ledger lock waits now cap at 2.5s and fail
25
+ open — a lost receipt beats a killed hook.
26
+
27
+ **P1 — vibecode had no exit when verification kept failing identically.** Two adjacent identical
28
+ failed verifications (no edit between) now mint a visible `verification-loop` blocker; vibecode
29
+ additionally escapes after the same command fails 4× across edits.
30
+
31
+ **P1 — clean map-impact Stop loop.** A completed analysis-only `map-impact` route still demanded
32
+ write + verification evidence. A narrow visible release valve permits a completed impact read
33
+ with no mutation attempt and no failed verification; incomplete analysis and partial edits stay
34
+ gated.
35
+
36
+ **P1 — omp hook child timeouts falsely blocked edits.** A 4s-killed `protect-files` gate was
37
+ treated as its fail-closed verdict, freezing all edits on a slow machine. Timeouts now fail open
38
+ as infrastructure failures with a visible warning; `block-dangerous.sh` stays fail-closed. An
39
+ unknown mutation-shaped omp tool now normalizes to `Edit` and runs the normal safety chain
40
+ instead of emitting an impossible "add host mapping" block.
41
+
42
+ **P1 — hard-cap grace slots crossed session boundaries.** Global grace slots let one session
43
+ spend or reset another session's landing allowance, stranding it mid-edit. Slots and advisory
44
+ state are now session-keyed.
45
+
46
+ **P2 — transcript fallback rescanned up to 4MB per mutation.** A persisted, session-bound
47
+ 2-second boundary-probe cache coalesces mutation bursts to one scan; `skill-router.sh` also
48
+ skips its redundant prompt-only Node spawn on tool payloads.
49
+
50
+ **P1 — lock stale-reclaim could steal a live owner.** The shared runtime lock used mtime-only
51
+ reclaim: a slow-but-alive holder was deleted after 10s, then deleted its successor's lock in
52
+ `finally` — lost receipts re-triggered completion debt. All lock implementations (runtime
53
+ mirror, verification guard, auto-allow, auto-prune, compact-pressure reset) now stamp
54
+ PID + random owner tokens, reclaim only provably-dead owners, and release only their own token.
55
+
56
+ **P3 — stale verification-guard enforcement env var froze runs.** An inherited
57
+ `UKIT_VERIFICATION_GUARD_ENFORCE=1` turned an advisory test-order recommendation into an
58
+ exit-2 block. The guard is now exclusively advisory; real safety gates keep enforcement.
59
+
60
+ Also in wave 7: piped/redirected verification (`yarn test 2>&1 | tail -6`) counts as targeted,
61
+ and delivery-only requests ("push this to git") route `informational` with no fabricated
62
+ mutation debt in all three routing copies.
63
+
64
+ Full suite: 81 files, 1,439 tests green (plus wave 9-10 focused regressions). No npm tag
65
+ policy change; artifact unpacked ceiling raised once to 5,785,000 with dated rationale.
66
+
67
+ ## 2.3.9 - 2026-09-10
68
+
69
+ Freeze-sweep wave 5: fixes for completion-gate loops, worker-agent dead ends, and malformed
70
+ context-cap configuration that could otherwise halt all mutation progress.
71
+
72
+ **P1 — clean `find-cause` audits were forced into a Stop-hook loop.** A recommend-only
73
+ investigation that found no actionable bug still required write and verification evidence, so
74
+ the completion gate blocked the honest outcome and demanded a fabricated edit. The execution
75
+ ledger now permits that narrow clean-audit outcome while retaining normal recovery blocks after
76
+ a failed verification or incomplete mutation.
77
+
78
+ **P1 — routed workers could wait for a user they cannot contact.** The Claude Code and omp
79
+ `bug-debugger` / `feature-implementer` agents instructed workers to “ask the user” on unclear
80
+ or non-reproducible work. They now perform one bounded next diagnostic step and return a
81
+ structured `STATUS: BLOCKED` report with the exact missing artifact or decision to the parent
82
+ agent, which owns user communication.
83
+
84
+ **P1 — malformed context-cap configuration could brick mutations.** Invalid
85
+ `compact.hardCapTokens` could collapse the cap to one token, invalid
86
+ `hardCapGraceCalls` could eliminate the landing window, and wrong-shaped present
87
+ `hardCapBlock` values could unexpectedly leave the hard gate enabled. The shared source/runtime
88
+ threshold logic and Claude hook now accept only positive integer budgets, while malformed
89
+ non-boolean `hardCapBlock` values fail open; the documented absent-key default remains enabled.
90
+ The context-window advisory uses the same hard-cap validation. Regression coverage includes
91
+ negative/zero/fraction/string/boolean/null budgets and malformed toggle values.
92
+
93
+ Full suite: 81 files, 1,396 tests green.
94
+
5
95
  ## 2.3.8 - 2026-09-10
6
96
 
7
97
  Freeze-sweep wave 4: every fix in this release targets the "agent silently stops working"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.3.8",
3
+ "version": "2.3.10",
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",
@@ -35,6 +35,15 @@ function finiteNumber(value, fallback = 0) {
35
35
  return Number.isFinite(number) ? number : fallback;
36
36
  }
37
37
 
38
+ // A malformed hard cap must fail back to a safe ceiling, never collapse to one token and
39
+ // brick every Edit/Write/Bash call. Unlike token estimates, configuration is an operator
40
+ // contract: accept only whole positive token counts.
41
+ function positiveInteger(value, fallback) {
42
+ return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0
43
+ ? value
44
+ : fallback;
45
+ }
46
+
38
47
  function uniqueStrings(values) {
39
48
  const seen = new Set();
40
49
  const unique = [];
@@ -434,11 +443,15 @@ export function buildCompactThresholds(config = {}) {
434
443
  );
435
444
  const hardThreshold = Math.max(softThreshold + 1, Math.round(softThreshold * 1.6));
436
445
  const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
446
+ // Keep the source mirror's config contract aligned with the installed runtime: malformed
447
+ // caps fall back to a safe ceiling rather than turning every mutation into an over-cap call.
448
+ const hardCapTokens = positiveInteger(config?.compact?.hardCapTokens, loadShippedCompactBudget().hardCapTokens);
437
449
 
438
450
  return {
439
451
  softThreshold,
440
452
  hardThreshold,
441
453
  baselineTokens,
454
+ hardCapTokens,
442
455
  };
443
456
  }
444
457
 
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
 
@@ -142,17 +143,46 @@ function sleep(ms) {
142
143
  return new Promise((resolve) => setTimeout(resolve, ms));
143
144
  }
144
145
 
146
+ function isPidAlive(pid) {
147
+ try {
148
+ process.kill(pid, 0);
149
+ return true;
150
+ } catch (error) {
151
+ // EPERM: the process exists but belongs to another user — still alive.
152
+ return error?.code === 'EPERM';
153
+ }
154
+ }
155
+
156
+ async function readLockOwner(lockPath) {
157
+ try {
158
+ const raw = JSON.parse(await fs.readFile(path.join(lockPath, 'owner'), 'utf8'));
159
+ const pid = Number(raw?.pid);
160
+ return Number.isInteger(pid) && pid > 0
161
+ ? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
162
+ : null;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ // Same-pid holders are parallel async flows whose liveness a pid probe cannot prove.
169
+ const inProcessLockHolders = new Map();
170
+
145
171
  /**
146
172
  * Serialize read-modify-write mutations of a shared state file — across processes
147
173
  * (hook invocations run as separate node processes) and across concurrent async
148
174
  * flows in one process (parallel subagents). The lock is a directory created next
149
175
  * to the target file: `mkdir` is atomic, so exactly one caller can create it.
150
- * A crashed holder is reclaimed once the directory's mtime exceeds staleMs.
176
+ * Ownership is recorded in an `owner` file inside the lock dir: stale reclaim first
177
+ * proves the recorded holder is gone (dead pid, or no in-process holder for our own
178
+ * pid — no owner file keeps legacy mtime-only recovery). Release only removes a dir
179
+ * this acquisition still owns, so a reclaimed-then-reacquired lock is never deleted
180
+ * out from under its successor.
151
181
  * Liveness wins over strictness: if the lock cannot be acquired within maxWaitMs
152
182
  * the callback runs anyway (the pre-lock behaviour) — these state files are
153
183
  * advisory caches, and losing an update beats freezing a hook mid-flight.
154
- * Holders must keep their critical section far below staleMs; nothing refreshes
155
- * the lock mtime, so a section that somehow runs longer can have its lock stolen.
184
+ * Protocol-compatible with the runtime token-utils.mjs lock (same `<file>.lock`
185
+ * path and owner-file format), so CLI and hook processes serialize against each other.
156
186
  * @param {string} filePath - state file the mutation targets (lock lives beside it)
157
187
  * @param {() => Promise<*>} fn - critical section; its result is returned
158
188
  * @returns {Promise<*>} whatever fn resolves with
@@ -160,24 +190,44 @@ function sleep(ms) {
160
190
  export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
161
191
  const lockPath = `${filePath}.lock`;
162
192
  const startedAt = Date.now();
193
+ const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
163
194
  let locked = false;
195
+ let ownerStamped = false;
164
196
 
165
197
  while (!locked) {
166
198
  try {
167
199
  await ensureDir(path.dirname(lockPath));
168
200
  await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
169
201
  locked = true;
202
+ inProcessLockHolders.set(lockPath, ownerToken);
203
+ try {
204
+ await fs.writeFile(
205
+ path.join(lockPath, 'owner'),
206
+ `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
207
+ 'utf8',
208
+ );
209
+ ownerStamped = true;
210
+ } catch {
211
+ ownerStamped = false; // unverifiable release skips removal; stale reclaim cleans up
212
+ }
170
213
  break;
171
214
  } catch (error) {
172
215
  if (error?.code !== 'EEXIST') throw error;
173
216
  }
174
217
 
175
- // Someone holds the lock. Reclaim it when it looks abandoned; otherwise back off.
218
+ // Someone holds the lock. Reclaim it only when the holder is provably gone.
176
219
  try {
177
220
  const stat = await fs.stat(lockPath);
178
221
  if (Date.now() - stat.mtimeMs > staleMs) {
179
- await fs.rm(lockPath, { recursive: true, force: true });
180
- continue; // the slot is free now — retry immediately
222
+ const owner = await readLockOwner(lockPath);
223
+ const liveInProcess = inProcessLockHolders.has(lockPath);
224
+ const reclaimable = !owner || owner.pid === process.pid
225
+ ? !liveInProcess
226
+ : !isPidAlive(owner.pid);
227
+ if (reclaimable) {
228
+ await fs.rm(lockPath, { recursive: true, force: true });
229
+ continue; // the slot is free now — retry immediately
230
+ }
181
231
  }
182
232
  } catch {
183
233
  continue; // lock vanished between mkdir and stat — retry immediately
@@ -192,7 +242,11 @@ export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxW
192
242
  } finally {
193
243
  if (locked) {
194
244
  try {
195
- await fs.rm(lockPath, { recursive: true, force: true });
245
+ const current = ownerStamped ? await readLockOwner(lockPath) : null;
246
+ if (current && current.token === ownerToken) {
247
+ await fs.rm(lockPath, { recursive: true, force: true });
248
+ }
249
+ if (inProcessLockHolders.get(lockPath) === ownerToken) inProcessLockHolders.delete(lockPath);
196
250
  } catch {
197
251
  // best-effort release; a stale lock is reclaimed by the next waiter
198
252
  }
@@ -78,6 +78,7 @@ export async function deriveTaskRoute({
78
78
  intentMode,
79
79
  executionScores,
80
80
  executionCandidates,
81
+ signalText: buildRouteSignalText(normalizedPrompt, normalizedCommand),
81
82
  });
82
83
  const preservedPrompt = normalizedPrompt || String(lastExplicitUserPromptText || '').trim();
83
84
  const degradedWarnings = [];
@@ -412,8 +413,10 @@ function deriveExecutionMode({
412
413
  intentMode = null,
413
414
  executionScores = {},
414
415
  executionCandidates = null,
416
+ signalText = '',
415
417
  } = {}) {
416
418
  const raw = `${promptText ?? ''}\n${commandText ?? ''}`.toLowerCase();
419
+ const signalRaw = `${raw}\n${String(signalText || '').toLowerCase()}`;
417
420
  const scores = executionScores;
418
421
  const candidates = executionCandidates ?? buildExecutionModeCandidates({
419
422
  promptText,
@@ -423,14 +426,44 @@ function deriveExecutionMode({
423
426
  executionScores,
424
427
  });
425
428
  const explicitReviewLead = /\b(review this|audit this|review\b.*\brelease readiness|verify\b.*\brelease readiness)\b/.test(raw);
429
+ const releaseVerificationContinuation = /\b(?:pending\s+diff[- ]review|self-install|verify\s+(?:consistency|release)|release\s+(?:verification|readiness))\b/.test(raw)
430
+ && /\b(?:release|tag|publish|push|report)\b/.test(raw);
426
431
  const strongImpactLead = scores.sharedRisk
427
432
  && (scores.impactSignal || /\b(check all affected|map all affected|across all affected)\b/.test(raw));
428
433
  const boundedLocalBuildCandidate = scores.buildSignal && scores.boundedEditSignal && scores.explicitTarget && !scores.sharedRisk;
434
+ // A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
435
+ // repository mutation the ledger could ever receipt. With no edit/review/debug/
436
+ // build signal present, routing it to an investigation lane fabricates write debt
437
+ // and the completion gate then demands an edit that cannot exist. Delivery-only
438
+ // requests carry no completion contract instead.
439
+ const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
440
+ || /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
441
+ || /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
442
+ || /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
443
+ const deliveryOnlySignal = deliveryWordSignal
444
+ && scores.editCertainty === 0
445
+ && !scores.implementSignal
446
+ && !scores.reviewSignal
447
+ && !scores.debugSignal
448
+ && !scores.failureSignal
449
+ && !scores.impactSignal
450
+ && !scores.buildSignal
451
+ && !scores.directTransformSignal
452
+ && !scores.smallFixSignal
453
+ && !scores.sharedRisk
454
+ && !targetFile;
429
455
 
430
- if ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal) {
456
+ if (
457
+ releaseVerificationContinuation
458
+ || ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal)
459
+ ) {
431
460
  return 'review-release';
432
461
  }
433
462
 
463
+ if (deliveryOnlySignal) {
464
+ return 'informational';
465
+ }
466
+
434
467
  if (strongImpactLead || (scores.blastRadius >= 3 && (scores.ambiguity >= 2 || scores.investigationNeed >= 2))) {
435
468
  return 'map-impact';
436
469
  }
@@ -530,7 +563,7 @@ function buildExecutionModeCandidates({
530
563
  ),
531
564
  'map-impact': (
532
565
  (scores.sharedRisk ? 7 : 0)
533
- + (scores.impactSignal ? 5 : 0)
566
+ + (scores.impactSignal ? 5 : -5)
534
567
  + (scores.ambiguity * 2)
535
568
  + scores.investigationNeed
536
569
  ),
@@ -18,7 +18,7 @@ Systematic debugging — understand before fixing.
18
18
 
19
19
  - Run the failing command/action.
20
20
  - Capture exact error message and stack trace.
21
- - If not reproducible → document conditions and ask user.
21
+ - If not reproducible → document conditions, run the next most-discriminating bounded repro, then report any exact missing user-only artifact/permission to the parent agent. Never wait for or ask a user directly — you are a worker.
22
22
 
23
23
  ### 2. Trace Root Cause
24
24
 
@@ -83,4 +83,4 @@ Daily mode: skip. Handoff mode: set task status `pending_review` in `INDEX.md`;
83
83
  - non-trivial bug: `docs/MEMORY.md` + `docs/PROJECT.md` + `docs/CODE_MAP.md`
84
84
  - read `docs/WORKLOG.md` only recent relevant entries
85
85
  - Keep fix scope minimal — no drive-by refactors.
86
- - If root cause is unclear after 5 minutes of tracing → ask user for more context.
86
+ - If root cause is unclear after 5 minutes of tracing → try one bounded alternative hypothesis/repro, then report precise evidence plus the smallest needed missing context to the parent agent. Never wait for or ask a user directly — the parent owns user communication.
@@ -12,7 +12,9 @@ Implement requested behavior with minimal scope drift.
12
12
  - **Daily/ad-hoc mode** (DEFAULT): task didn't come from `docs/AI_HANDOFF/` → use the original lightweight workflow. Tests only when touched code already has coverage. No reviewer trigger.
13
13
  - **Handoff mode**: task file is `docs/AI_HANDOFF/tasks/TASK-xxx.md` OR user explicitly invokes handoff (e.g. "execute task TASK-001") → activate full Quality Gate: test-first → green → reviewer.
14
14
 
15
- If unsure which mode applies, ask the user. Don't apply Handoff mode rules to a quick one-off fix.
15
+ If unsure which mode applies, default to Daily/ad-hoc mode unless the task path or prompt
16
+ explicitly selects Handoff. Do not ask a user — you are a worker; report the ambiguity and
17
+ reasoning to the parent agent so it can decide whether to re-route.
16
18
 
17
19
  **In Handoff mode you are running unattended — ask nothing.** You were spawned by an
18
20
  orchestrator driving a pipeline; there is no human in your conversation to answer, and a
@@ -33,7 +35,7 @@ and even then, report it, don't ask about it.
33
35
  - non-trivial: `docs/MEMORY.md` + `docs/PROJECT.md` + `docs/CODE_MAP.md`
34
36
  - Identify target files and existing patterns.
35
37
  - If task came from handoff, read `tasks/TASK-xxx.md` and locate its **Test Plan** + **Verification Commands**.
36
- - Daily mode: if confidence is low or risk is high, ask one short clarifying question before deeper analysis. Handoff mode: do not ask — decide and record the decision (see above).
38
+ - Daily mode: if confidence is low or risk is high, inspect the next bounded source/context signal and hand back a concise `STATUS: BLOCKED` report with the exact missing decision or artifact if confidence remains low. Do not ask a user directly. Handoff mode: do not ask — decide and record the decision (see above).
37
39
 
38
40
  ### 2. Plan Approach (< 1 minute)
39
41
 
@@ -44,7 +46,7 @@ and even then, report it, don't ask about it.
44
46
 
45
47
  - Write the test(s) from §2 / from task Test Plan.
46
48
  - Run them: must FAIL for the expected reason. Capture output.
47
- - If test passes immediately → test is wrong or behavior already exists. Fix the test or stop and report.
49
+ - If test passes immediately → test is wrong or behavior already exists. Fix the test; if the intended behavior cannot be inferred, hand back `STATUS: BLOCKED` with the observed behavior and the smallest decision the parent must resolve. Never silently stop.
48
50
  - **Daily mode**: skip this step unless touched code already has tests (then follow original rule).
49
51
 
50
52
 
@@ -78,6 +78,7 @@ MAX_RULES="${CLAUDE_AUTO_ALLOW_MAX_RULES:-150}"
78
78
  # Liveness over strictness: if the lock cannot be acquired within maxWaitMs the mutation
79
79
  # runs anyway (pre-lock behaviour) — this hook must never hang a PreToolUse chain.
80
80
  node -e '
81
+ const crypto = require("crypto");
81
82
  const fs = require("fs");
82
83
  const path = require("path");
83
84
 
@@ -115,24 +116,57 @@ function appendAudit(event, payload) {
115
116
  fs.appendFileSync(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
116
117
  }
117
118
 
119
+ function isPidAlive(pid) {
120
+ if (!Number.isInteger(pid) || pid <= 0) return false;
121
+ try {
122
+ process.kill(pid, 0);
123
+ return true;
124
+ } catch (error) {
125
+ return error?.code === "EPERM";
126
+ }
127
+ }
128
+
129
+ function readLockOwner(lockPath) {
130
+ const owner = readJson(path.join(lockPath, "owner"), null);
131
+ return owner && Number.isInteger(owner.pid) && typeof owner.token === "string"
132
+ ? owner
133
+ : null;
134
+ }
135
+
118
136
  async function withLock(lockPath, fn) {
119
137
  const staleMs = 10000;
120
138
  const maxWaitMs = 5000;
121
139
  const startedAt = Date.now();
140
+ const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString("hex")}`;
122
141
  let held = false;
123
142
  while (!held) {
124
143
  try {
125
144
  fs.mkdirSync(lockPath);
145
+ try {
146
+ fs.writeFileSync(path.join(lockPath, "owner"), JSON.stringify({
147
+ pid: process.pid,
148
+ token: ownerToken,
149
+ ts: Date.now(),
150
+ }));
151
+ } catch (error) {
152
+ try {
153
+ fs.rmSync(lockPath, { recursive: true, force: true });
154
+ } catch {}
155
+ throw error;
156
+ }
126
157
  held = true;
127
158
  } catch (error) {
128
159
  if (!error || error.code !== "EEXIST") return fn();
129
160
  try {
130
161
  const stat = fs.statSync(lockPath);
131
162
  if (Date.now() - stat.mtimeMs > staleMs) {
132
- try {
133
- fs.rmSync(lockPath, { recursive: true, force: true });
134
- continue;
135
- } catch {}
163
+ const owner = readLockOwner(lockPath);
164
+ if (!owner || !isPidAlive(owner.pid)) {
165
+ try {
166
+ fs.rmSync(lockPath, { recursive: true, force: true });
167
+ continue;
168
+ } catch {}
169
+ }
136
170
  }
137
171
  } catch {}
138
172
  if (Date.now() - startedAt > maxWaitMs) break;
@@ -143,9 +177,12 @@ async function withLock(lockPath, fn) {
143
177
  return await fn();
144
178
  } finally {
145
179
  if (held) {
146
- try {
147
- fs.rmdirSync(lockPath);
148
- } catch {}
180
+ const owner = readLockOwner(lockPath);
181
+ if (owner?.token === ownerToken) {
182
+ try {
183
+ fs.rmSync(lockPath, { recursive: true, force: true });
184
+ } catch {}
185
+ }
149
186
  }
150
187
  }
151
188
  }
@@ -14,6 +14,7 @@ fi
14
14
  NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
15
15
 
16
16
  node -e '
17
+ const crypto = require("crypto");
17
18
  const fs = require("fs");
18
19
  const path = require("path");
19
20
 
@@ -55,25 +56,58 @@ function appendAudit(event, payload) {
55
56
  // writes because Claude Code re-reads settings.local.json — a torn write would drop the
56
57
  // ENTIRE allow list and every command would prompt again. Fail-open after 5s: a hook
57
58
  // must never hang the chain.
59
+ function isPidAlive(pid) {
60
+ if (!Number.isInteger(pid) || pid <= 0) return false;
61
+ try {
62
+ process.kill(pid, 0);
63
+ return true;
64
+ } catch (error) {
65
+ return error?.code === "EPERM";
66
+ }
67
+ }
68
+
69
+ function readLockOwner(lockPath) {
70
+ const owner = readJson(path.join(lockPath, "owner"), null);
71
+ return owner && Number.isInteger(owner.pid) && typeof owner.token === "string"
72
+ ? owner
73
+ : null;
74
+ }
75
+
58
76
  async function withLock(lockPath, fn) {
59
77
  const staleMs = 10000;
60
78
  const maxWaitMs = 5000;
61
79
  const startedAt = Date.now();
80
+ const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString("hex")}`;
62
81
  let held = false;
63
82
  while (!held) {
64
83
  try {
65
84
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
66
85
  fs.mkdirSync(lockPath);
86
+ try {
87
+ fs.writeFileSync(path.join(lockPath, "owner"), JSON.stringify({
88
+ pid: process.pid,
89
+ token: ownerToken,
90
+ ts: Date.now(),
91
+ }));
92
+ } catch (error) {
93
+ try {
94
+ fs.rmSync(lockPath, { recursive: true, force: true });
95
+ } catch {}
96
+ throw error;
97
+ }
67
98
  held = true;
68
99
  } catch (error) {
69
100
  if (!error || error.code !== "EEXIST") return fn();
70
101
  try {
71
102
  const stat = fs.statSync(lockPath);
72
103
  if (Date.now() - stat.mtimeMs > staleMs) {
73
- try {
74
- fs.rmSync(lockPath, { recursive: true, force: true });
75
- continue;
76
- } catch {}
104
+ const owner = readLockOwner(lockPath);
105
+ if (!owner || !isPidAlive(owner.pid)) {
106
+ try {
107
+ fs.rmSync(lockPath, { recursive: true, force: true });
108
+ continue;
109
+ } catch {}
110
+ }
77
111
  }
78
112
  } catch {}
79
113
  if (Date.now() - startedAt > maxWaitMs) break;
@@ -84,9 +118,12 @@ async function withLock(lockPath, fn) {
84
118
  return await fn();
85
119
  } finally {
86
120
  if (held) {
87
- try {
88
- fs.rmdirSync(lockPath);
89
- } catch {}
121
+ const owner = readLockOwner(lockPath);
122
+ if (owner?.token === ownerToken) {
123
+ try {
124
+ fs.rmSync(lockPath, { recursive: true, force: true });
125
+ } catch {}
126
+ }
90
127
  }
91
128
  }
92
129
  }
@@ -96,7 +96,12 @@ function readRunCursor() {
96
96
  ? payload.session_id.trim()
97
97
  : undefined,
98
98
  };
99
- if (config?.compact?.hardCapBlock === false) {
99
+ // This is a liveness backstop, not a security control. A present value with the wrong
100
+ // JSON shape must never leave the gate unexpectedly enabled and brick every mutation once
101
+ // the cap is reached. Only an explicitly configured boolean `true` enables the gate;
102
+ // missing preserves the documented default-on behavior for existing installations.
103
+ const rawHardCapBlock = config?.compact?.hardCapBlock;
104
+ if (rawHardCapBlock === false || (rawHardCapBlock !== undefined && typeof rawHardCapBlock !== 'boolean')) {
100
105
  process.exit(0);
101
106
  return;
102
107
  }
@@ -123,7 +128,9 @@ function readRunCursor() {
123
128
  let state = mod.buildCompactPressureState(rawState, sessionConfig);
124
129
  try {
125
130
  const synced = await mod.syncCompactPressureStateWithTranscript(state, sessionConfig, payload.transcript_path);
126
- if (synced.reset) {
131
+ if (synced.reset || synced.probed) {
132
+ // Persist a clean probe too: hook processes are ephemeral, so otherwise every
133
+ // mutation would reopen and parse the same 4MB transcript tail.
127
134
  state = await mod.writeCompactPressureState(projectRoot, synced.state, sessionConfig);
128
135
  }
129
136
  } catch {
@@ -136,13 +143,24 @@ function readRunCursor() {
136
143
  // both write 4, silently multiplying the budget), so each spent call is claimed with an
137
144
  // exclusive-create sentinel file (openSync 'wx'): the sentinels present ARE the number
138
145
  // of grace calls spent, and one claim can never be counted twice.
139
- const gracePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'hardcap-grace.json');
140
- const graceDir = path.dirname(gracePath);
141
- const slotPath = (n) => path.join(graceDir, `hardcap-grace.json.${n}`);
146
+ // Grace is a landing allowance for THIS session's over-cap episode. The old global
147
+ // `hardcap-grace.json.N` slot pool let session A consume session B's allowance (and A
148
+ // clearing it refilled B's spent pool), so parallel sessions could be hard-blocked mid-
149
+ // landing for no reason. Keep the fallback `default` for hosts that omit session_id: in
150
+ // that legacy shape sibling workers still deliberately share one bounded allowance.
151
+ const rawGraceSessionId = typeof payload.session_id === 'string' && payload.session_id.trim()
152
+ ? payload.session_id.trim()
153
+ : 'default';
154
+ const graceSessionKey = rawGraceSessionId.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'default';
155
+ const graceDir = path.join(projectRoot, '.ukit', 'storage', 'cache');
156
+ const graceBaseName = `hardcap-grace.${graceSessionKey}.json`;
157
+ const gracePath = path.join(graceDir, graceBaseName);
158
+ const slotPath = (n) => path.join(graceDir, `${graceBaseName}.${n}`);
159
+ const slotPattern = new RegExp(`^${graceBaseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.(\\d+)$`);
142
160
  const readSlots = () => {
143
161
  try {
144
162
  return fs.readdirSync(graceDir)
145
- .map((name) => /^hardcap-grace\.json\.(\d+)$/.exec(name))
163
+ .map((name) => slotPattern.exec(name))
146
164
  .filter(Boolean)
147
165
  .map((m) => Number(m[1]))
148
166
  .sort((a, b) => a - b);
@@ -175,8 +193,15 @@ function readRunCursor() {
175
193
  }
176
194
  const resumable = run || ordinaryTask;
177
195
  if (resumable) {
178
- const graceCalls = Number.isFinite(config?.compact?.hardCapGraceCalls)
179
- ? config.compact.hardCapGraceCalls
196
+ // Invalid config must not turn the landing allowance negative/zero (which makes every
197
+ // unfinished run hard-block immediately). This hook is a liveness backstop, so malformed
198
+ // values deliberately fall back to the documented default rather than fail closed.
199
+ const configuredGraceCalls = config?.compact?.hardCapGraceCalls;
200
+ const graceCalls = typeof configuredGraceCalls === 'number'
201
+ && Number.isFinite(configuredGraceCalls)
202
+ && Number.isInteger(configuredGraceCalls)
203
+ && configuredGraceCalls > 0
204
+ ? configuredGraceCalls
180
205
  : 10;
181
206
 
182
207
  let slots = readSlots();
@@ -73,7 +73,7 @@ function loadHardCap() {
73
73
  try {
74
74
  const raw = fs.readFileSync(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8');
75
75
  const value = JSON.parse(raw)?.compact?.hardCapTokens;
76
- if (Number.isFinite(value) && value > 0) return value;
76
+ if (typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0) return value;
77
77
  } catch { /* fall through to the default */ }
78
78
  return 500_000;
79
79
  }