@ngockhoale/ukit 2.7.7 → 2.7.8

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/package.json +1 -1
  3. package/src/context/detectProjectContext.js +5 -0
  4. package/src/core/codeintel/invalidation.js +4 -0
  5. package/src/core/fileOps.js +40 -117
  6. package/src/render/buildVariables.js +10 -0
  7. package/templates/.claude/agents/bug-debugger.md +1 -1
  8. package/templates/.claude/agents/feature-implementer.md +2 -2
  9. package/templates/.claude/commands/ukit/handoff-create.md +1 -1
  10. package/templates/.claude/commands/ukit/handoff-fullstack.md +1 -1
  11. package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
  12. package/templates/.claude/commands/ukit/handoff-review.md +1 -1
  13. package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
  14. package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
  15. package/templates/.claude/hooks/reset-compact-pressure.sh +10 -0
  16. package/templates/.claude/hooks/skill-router.sh +15 -8
  17. package/templates/.claude/hooks/verification-guard.sh +3 -0
  18. package/templates/.claude/ukit/index/route-task.mjs +237 -32
  19. package/templates/.claude/ukit/runtime/async-lock.mjs +144 -10
  20. package/templates/.claude/ukit/runtime/compact-threshold.mjs +5 -2
  21. package/templates/.claude/ukit/runtime/execution-ledger.mjs +217 -17
  22. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +38 -4
  23. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +57 -0
  24. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +35 -20
  25. package/templates/.claude/ukit/runtime/token-utils.mjs +37 -126
  26. package/templates/.codex/settings.json +1 -5
  27. package/templates/.omp/agents/bug-debugger.md +1 -1
  28. package/templates/.omp/agents/feature-implementer.md +2 -2
  29. package/templates/.omp/hooks/pre/ukit-bridge.js +157 -26
  30. package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
  31. package/templates/docs/AI_HANDOFF/RULES.md +6 -6
  32. package/templates/ukit/storage/config.json +2 -2
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs/promises';
3
- import { realpathSync } from 'node:fs';
3
+ // `fsSync` aliases the SAME fs module — used ONLY by the process.on('exit')
4
+ // audit-sidecar fold below (event loop is gone; async cannot run) and by the
5
+ // module-bottom direct-CLI check. Normal-path fs stays async.
6
+ import * as fsSync from 'node:fs';
4
7
  import path from 'node:path';
5
8
  import crypto from 'node:crypto';
6
9
  import { spawnSync } from 'node:child_process';
@@ -13,6 +16,7 @@ import {
13
16
  } from './cache-utils.mjs';
14
17
  import { ROUTE_CATALOG } from './route-catalog.mjs';
15
18
  import { detectUnicGateway } from './unic-gateway.mjs';
19
+ import { withAsyncLock } from '../runtime/async-lock.mjs';
16
20
 
17
21
  const {
18
22
  resolveContext,
@@ -946,7 +950,7 @@ async function main() {
946
950
  };
947
951
  sharedState.fingerprint = buildRouteStateFingerprint(sharedState);
948
952
  if (canPersistCachedRouteState) {
949
- await writeJson(sharedStatePath, sharedState);
953
+ await persistSharedRouteState(sharedStatePath, sharedState);
950
954
  await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
951
955
  state: sharedState,
952
956
  }));
@@ -991,7 +995,7 @@ async function main() {
991
995
  sessionId,
992
996
  });
993
997
  if (canPersistRouteState) {
994
- await writeJson(sharedStatePath, sharedState);
998
+ await persistSharedRouteState(sharedStatePath, sharedState);
995
999
  await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
996
1000
  route,
997
1001
  state: sharedState,
@@ -3816,35 +3820,241 @@ function buildRouteAuditEntry({ route = null, state = null } = {}) {
3816
3820
  };
3817
3821
  }
3818
3822
 
3819
- async function appendRouteAuditEntry(filePath, entry) {
3823
+ // CX-8: the shared route-state/audit writes must obey the same protocol as the
3824
+ // claude/omp writers (skill-router.sh + token-utils withFileLock): tmp+rename
3825
+ // atomicity AND the owner-token `<file>.lock` serialization. The pre-fix bare
3826
+ // fs.writeFile raced the locked writers — a torn skill-router-state.json parses
3827
+ // as `{}` downstream and releases a premature Stop; the unlocked audit
3828
+ // read-modify-write lost entries under concurrent routers.
3829
+ export async function writeJsonAtomic(filePath, value) {
3830
+ // tmp+rename: readers see old-or-new, never a torn/half file.
3831
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
3832
+ const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
3833
+ try {
3834
+ await fs.writeFile(tempPath, JSON.stringify(value), 'utf8');
3835
+ await fs.rename(tempPath, filePath);
3836
+ } catch (error) {
3837
+ try {
3838
+ await fs.rm(tempPath, { force: true });
3839
+ } catch {
3840
+ // ignore cleanup errors
3841
+ }
3842
+ throw error;
3843
+ }
3844
+ }
3845
+
3846
+ // The state write is a pure overwrite, but it must still serialize under the
3847
+ // owner-token lock so a codex writer cannot interleave with a locked claude/omp
3848
+ // writer mid-flight. Fail-closed (CX-8): a busy lock skips the write — the next
3849
+ // route recomputes and overwrites the whole document anyway, so a skipped
3850
+ // overwrite loses nothing; an unlocked one can interleave with a live holder.
3851
+ export async function persistSharedRouteState(filePath, state) {
3852
+ const result = await withAsyncLock(filePath, {}, () => writeJsonAtomic(filePath, state));
3853
+ if (!result.ok) {
3854
+ console.error(`route-task: skill-router-state lock ${result.reason} after ${result.waitedMs}ms — state write skipped (fail-closed)`);
3855
+ }
3856
+ return result;
3857
+ }
3858
+
3859
+ // Ported from skill-router.sh entryDedupeKey — `|| null` (not `?? null`) so
3860
+ // falsy fields like repeatCount: 0 produce the same digest in both engines and
3861
+ // cross-engine dedupe of the shared route-audit.json cannot duplicate entries.
3862
+ function routeAuditEntryDedupeKey(item) {
3863
+ return stableMachineDigest({
3864
+ requestKey: item?.requestKey || null,
3865
+ executionMode: item?.executionMode || null,
3866
+ nextActionType: item?.nextActionType || null,
3867
+ nextMilestone: item?.nextMilestone || null,
3868
+ repeatCount: item?.repeatCount || null,
3869
+ rescueMode: item?.rescueMode || null,
3870
+ });
3871
+ }
3872
+
3873
+ // Merge order: the fresh entry first, then staged sidecar lines, then the
3874
+ // existing file. A staged/existing row with the new entry's dedupe key is
3875
+ // replaced by the fresh entry; every other key keeps its first occurrence.
3876
+ function mergeRouteAuditEntries(staged, parsed, newEntry) {
3877
+ const dedupeKey = newEntry ? routeAuditEntryDedupeKey(newEntry) : null;
3878
+ const merged = [];
3879
+ const seen = new Set();
3880
+ for (const item of [newEntry, ...staged, ...(Array.isArray(parsed?.entries) ? parsed.entries : [])]) {
3881
+ if (!item || typeof item !== 'object') continue;
3882
+ const key = routeAuditEntryDedupeKey(item);
3883
+ if (dedupeKey && key === dedupeKey && item !== newEntry) continue;
3884
+ if (seen.has(key)) continue;
3885
+ seen.add(key);
3886
+ merged.push(item);
3887
+ }
3888
+ return merged.slice(0, 40);
3889
+ }
3890
+
3891
+ function parseRouteAuditSidecar(raw) {
3892
+ const recovered = [];
3893
+ for (const line of String(raw || '').split('\n')) {
3894
+ if (!line.trim()) continue;
3895
+ try {
3896
+ const item = JSON.parse(line);
3897
+ if (item && typeof item === 'object') recovered.push(item);
3898
+ } catch {
3899
+ // skip unparseable sidecar lines
3900
+ }
3901
+ }
3902
+ return recovered;
3903
+ }
3904
+
3905
+ function isPidAlive(pid) {
3906
+ if (!Number.isInteger(pid) || pid <= 0) return false;
3907
+ try {
3908
+ process.kill(pid, 0);
3909
+ return true;
3910
+ } catch (error) {
3911
+ // EPERM: the process exists but belongs to another user — still alive.
3912
+ return error?.code === 'EPERM';
3913
+ }
3914
+ }
3915
+
3916
+ // Synchronous last-chance fold for the process-exit path: process.on('exit')
3917
+ // handlers run without the event loop, so sync fs is legal here (fsSync alias).
3918
+ // Bounded spin-retry on the mkdir lock so the LAST exiting process still folds
3919
+ // staged entries after the previous holder releases. The sidecar is claimed by
3920
+ // rename (atomic): appends in flight land on the claimed inode and are read,
3921
+ // appends after the claim create a fresh sidecar for the next fold.
3922
+ function foldRouteAuditSidecarSync(filePath) {
3923
+ const sidecarPath = `${filePath}.unlocked.jsonl`;
3924
+ const lockPath = `${filePath}.lock`;
3925
+ const deadline = Date.now() + 300;
3926
+ let held = false;
3927
+ while (!held && Date.now() < deadline) {
3928
+ try {
3929
+ fsSync.mkdirSync(lockPath);
3930
+ held = true;
3931
+ } catch (error) {
3932
+ if (!error || error.code !== 'EEXIST') return;
3933
+ try {
3934
+ const stat = fsSync.statSync(lockPath);
3935
+ let owner = null;
3936
+ try {
3937
+ owner = JSON.parse(fsSync.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
3938
+ } catch {
3939
+ // unreadable owner — treated as no owner below
3940
+ }
3941
+ // An exit during our own async merge abandons our lock; reclaim it. A
3942
+ // live foreign owner stays protected; a dead/stale one is reclaimed.
3943
+ const ownInterruptedLock = owner?.pid === process.pid;
3944
+ if (ownInterruptedLock || Date.now() - stat.mtimeMs > 10000) {
3945
+ if (ownInterruptedLock || !owner || !isPidAlive(owner.pid)) {
3946
+ fsSync.rmSync(lockPath, { recursive: true, force: true });
3947
+ continue;
3948
+ }
3949
+ }
3950
+ } catch {
3951
+ // stat failed — fall through to the bounded spin
3952
+ }
3953
+ const until = Date.now() + 5;
3954
+ while (Date.now() < until) { /* bounded spin */ }
3955
+ }
3956
+ }
3957
+ if (!held) return;
3958
+ try {
3959
+ const claimedPath = `${sidecarPath}.claim-${process.pid}-${Date.now()}`;
3960
+ try {
3961
+ fsSync.renameSync(sidecarPath, claimedPath);
3962
+ } catch {
3963
+ return; // no staged sidecar — nothing to fold
3964
+ }
3965
+ let staged = [];
3966
+ try {
3967
+ staged = parseRouteAuditSidecar(fsSync.readFileSync(claimedPath, 'utf8'));
3968
+ } catch {
3969
+ // unreadable claim — treated as empty below
3970
+ }
3971
+ try {
3972
+ fsSync.rmSync(claimedPath, { force: true });
3973
+ } catch {
3974
+ // best-effort cleanup
3975
+ }
3976
+ if (!staged.length) return;
3977
+ let parsed = { entries: [] };
3978
+ try {
3979
+ parsed = JSON.parse(fsSync.readFileSync(filePath, 'utf8'));
3980
+ } catch {
3981
+ parsed = { entries: [] };
3982
+ }
3983
+ const tmpPath = `${filePath}.exitfold-${process.pid}`;
3984
+ fsSync.writeFileSync(tmpPath, JSON.stringify({
3985
+ entries: mergeRouteAuditEntries(staged, parsed, null),
3986
+ }));
3987
+ fsSync.renameSync(tmpPath, filePath);
3988
+ } finally {
3989
+ try {
3990
+ fsSync.rmSync(lockPath, { recursive: true, force: true });
3991
+ } catch {
3992
+ // best-effort release
3993
+ }
3994
+ }
3995
+ }
3996
+
3997
+ const armedRouteAuditFolds = new Set();
3998
+
3999
+ export async function appendRouteAuditEntry(filePath, entry) {
3820
4000
  if (!entry || typeof entry !== 'object') {
3821
4001
  return;
3822
4002
  }
4003
+ // The sandbox starts without .ukit/storage/cache. Create it before the
4004
+ // sidecar append and lock acquisition; otherwise both fail with ENOENT.
4005
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
3823
4006
 
3824
- const existing = await readJson(filePath, { entries: [] });
3825
- const entries = Array.isArray(existing?.entries) ? existing.entries : [];
3826
- const dedupeKey = stableMachineDigest({
3827
- requestKey: entry.requestKey,
3828
- executionMode: entry.executionMode,
3829
- nextActionType: entry.nextActionType,
3830
- nextMilestone: entry.nextMilestone,
3831
- repeatCount: entry.repeatCount,
3832
- rescueMode: entry.rescueMode,
3833
- });
3834
- const filtered = entries.filter((item) => {
3835
- const itemKey = stableMachineDigest({
3836
- requestKey: item?.requestKey ?? null,
3837
- executionMode: item?.executionMode ?? null,
3838
- nextActionType: item?.nextActionType ?? null,
3839
- nextMilestone: item?.nextMilestone ?? null,
3840
- repeatCount: item?.repeatCount ?? null,
3841
- rescueMode: item?.rescueMode ?? null,
4007
+ // The entry is STAGED to an append-only sidecar before the lock is attempted
4008
+ // (small appendFile is atomic — no read-modify-write, so no lost updates even
4009
+ // under full contention). Every lock holder folds all staged entries into
4010
+ // route-audit.json inside its merge; a synchronous last-chance fold is also
4011
+ // armed on process exit — whichever process exits last folds every staged
4012
+ // entry. The audit entry can never be silently dropped.
4013
+ const sidecarPath = `${filePath}.unlocked.jsonl`;
4014
+ try {
4015
+ await fs.appendFile(sidecarPath, `${JSON.stringify(entry)}\n`);
4016
+ } catch {
4017
+ // staging failed — still attempt the locked merge so the entry is not lost
4018
+ }
4019
+ if (!armedRouteAuditFolds.has(filePath)) {
4020
+ armedRouteAuditFolds.add(filePath);
4021
+ process.on('exit', () => foldRouteAuditSidecarSync(filePath));
4022
+ }
4023
+
4024
+ const result = await withAsyncLock(filePath, {}, async () => {
4025
+ // Claim staged lines atomically inside the lock: rename detaches the inode,
4026
+ // so lines appended during the fold land in a fresh sidecar for the next
4027
+ // pass instead of being deleted unread.
4028
+ const claimedPath = `${sidecarPath}.claim-${process.pid}-${Date.now()}`;
4029
+ let claimed = false;
4030
+ try {
4031
+ await fs.rename(sidecarPath, claimedPath);
4032
+ claimed = true;
4033
+ } catch {
4034
+ // no sidecar — nothing staged
4035
+ }
4036
+ const sidecarEntries = claimed
4037
+ ? parseRouteAuditSidecar(await fs.readFile(claimedPath, 'utf8').catch(() => ''))
4038
+ : [];
4039
+ if (claimed) {
4040
+ try {
4041
+ await fs.rm(claimedPath, { force: true });
4042
+ } catch {
4043
+ // best-effort cleanup
4044
+ }
4045
+ }
4046
+
4047
+ const parsed = await readJson(filePath, { entries: [] });
4048
+ await writeJsonAtomic(filePath, {
4049
+ entries: mergeRouteAuditEntries(sidecarEntries, parsed, entry),
3842
4050
  });
3843
- return itemKey !== dedupeKey;
3844
- });
3845
- await writeJson(filePath, {
3846
- entries: [entry, ...filtered].slice(0, 40),
3847
4051
  });
4052
+ if (!result.ok) {
4053
+ // Fail-closed on the merge (CX-8): the entry stays staged in the sidecar —
4054
+ // the next locked merge or the exit fold picks it up.
4055
+ console.error(`route-task: route-audit lock ${result.reason} after ${result.waitedMs}ms — audit entry staged to the append-only sidecar`);
4056
+ }
4057
+ return result;
3848
4058
  }
3849
4059
 
3850
4060
  async function readJson(filePath, fallback = null) {
@@ -3855,11 +4065,6 @@ async function readJson(filePath, fallback = null) {
3855
4065
  }
3856
4066
  }
3857
4067
 
3858
- async function writeJson(filePath, value) {
3859
- await fs.mkdir(path.dirname(filePath), { recursive: true });
3860
- await fs.writeFile(filePath, JSON.stringify(value), 'utf8');
3861
- }
3862
-
3863
4068
  function normalize(text) {
3864
4069
  return String(text ?? '')
3865
4070
  .toLowerCase()
@@ -4233,7 +4438,7 @@ function isDirectCliInvocation() {
4233
4438
  if (import.meta.url === pathToFileURL(entry).href) {
4234
4439
  return true;
4235
4440
  }
4236
- return import.meta.url === pathToFileURL(realpathSync(entry)).href;
4441
+ return import.meta.url === pathToFileURL(fsSync.realpathSync(entry)).href;
4237
4442
  } catch {
4238
4443
  return false;
4239
4444
  }
@@ -2,7 +2,10 @@
2
2
  // TASK-028: the one async lock utility for hook processes. Every hook lock used to
3
3
  // carry its own inline mkdir/owner/backoff copy (auto-allow-bash, verification-guard,
4
4
  // token-utils withFileLock, src/core/fileOps) with slightly divergent policies; this
5
- // module is the shared protocol-compatible implementation for hook-side callers:
5
+ // module is now the single implementation for all of them — the two withFileLock
6
+ // twins (token-utils.mjs and src/core/fileOps.js) delegate here, so the lock
7
+ // protocol, pstart/recycled-pid validation, and claim+quarantine reclaim exist
8
+ // exactly once and cannot drift between copies. For hook-side callers:
6
9
  //
7
10
  // - Acquisition is an atomic `mkdir` of `<target>.lock`; the owner is stamped into
8
11
  // `owner` (pid + random token + ts) so stale reclaim can be validated instead of
@@ -26,6 +29,7 @@
26
29
  // ownerless lock: a contender reads a missing owner as reclaimable after `staleMs`,
27
30
  // so an unstamped live holder could have its lock deleted mid-critical-section.
28
31
  import crypto from 'node:crypto';
32
+ import { spawnSync } from 'node:child_process';
29
33
  import fs from 'node:fs/promises';
30
34
  import path from 'node:path';
31
35
 
@@ -40,6 +44,10 @@ export const LOCK_MAX_SLICE_MS = 1_200;
40
44
  const BACKOFF_MIN_MS = 3;
41
45
  const BACKOFF_SPREAD_MS = 9;
42
46
 
47
+ // Tolerance for comparing a stamped process start time against `ps`-reported start:
48
+ // etimes has 1s granularity and the stamp is taken a few ms after the real start.
49
+ const PID_START_TOLERANCE_MS = 3_000;
50
+
43
51
  /**
44
52
  * Derive the lock acquisition budget from a hook's wall-clock deadline: the time
45
53
  * remaining before that deadline, minus a reserve for release/cleanup, capped at the
@@ -96,12 +104,66 @@ function isPidAlive(pid) {
96
104
  }
97
105
  }
98
106
 
107
+ // Wall-clock ms when `pid` started, or null when it cannot be determined. `lstart`
108
+ // is POSIX-portable across macOS and Linux ps (BSD ps has no `etimes`); the ctime
109
+ // shape is parsed manually so no Date.parse implementation quirk can misread it.
110
+ // A failed probe is treated as "unknown", never as "dead".
111
+ const LSTART_MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
112
+ const LSTART_RE = /^[A-Za-z]{3}\s+([A-Za-z]{3})\s+(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})\s+(\d{4})$/;
113
+ function processStartMs(pid) {
114
+ try {
115
+ const result = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
116
+ encoding: 'utf8',
117
+ timeout: 2_000,
118
+ });
119
+ if (result.status !== 0) return null;
120
+ const match = String(result.stdout || '').replace(/\s+/g, ' ').trim().match(LSTART_RE);
121
+ if (!match) return null;
122
+ const month = LSTART_MONTHS[match[1]];
123
+ if (month === undefined) return null;
124
+ const ms = new Date(
125
+ Number(match[6]), month, Number(match[2]),
126
+ Number(match[3]), Number(match[4]), Number(match[5]),
127
+ ).getTime();
128
+ return Number.isFinite(ms) ? ms : null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ // This process's own start time, stamped into every owner/reclaim record so a later
135
+ // observer can tell a recycled pid (same number, different process) from the real
136
+ // holder. `process.kill(pid, 0)` alone cannot: a reused pid probes alive forever and
137
+ // used to pin every stale lock unreclaimable (RC-4 PID-reuse residual).
138
+ const PROCESS_START_MS = Date.now() - Math.floor(process.uptime() * 1000);
139
+
140
+ // Provably-gone check for a recorded owner/claim { pid, token, pstart? }:
141
+ // - dead pid probe → gone;
142
+ // - live pid + stamped pstart that does not match the running process's start → the
143
+ // pid was recycled after the holder died → gone;
144
+ // - live pid with no stamp (legacy record) or an undeterminable start → alive
145
+ // (conservative: a live holder's lock is never stolen).
146
+ function recordedProcessGone(owner) {
147
+ if (!owner) return true;
148
+ if (!isPidAlive(owner.pid)) return true;
149
+ const stamped = Number(owner.pstart);
150
+ if (!Number.isFinite(stamped) || stamped <= 0) return false;
151
+ const actual = processStartMs(owner.pid);
152
+ if (actual === null) return false;
153
+ return Math.abs(actual - stamped) > PID_START_TOLERANCE_MS;
154
+ }
155
+
99
156
  async function readLockOwner(lockPath) {
100
157
  try {
101
158
  const raw = JSON.parse(await fs.readFile(path.join(lockPath, 'owner'), 'utf8'));
102
159
  const pid = Number(raw?.pid);
160
+ const pstart = Number(raw?.pstart);
103
161
  return Number.isInteger(pid) && pid > 0
104
- ? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
162
+ ? {
163
+ pid,
164
+ token: typeof raw?.token === 'string' ? raw.token : null,
165
+ pstart: Number.isFinite(pstart) && pstart > 0 ? pstart : null,
166
+ }
105
167
  : null;
106
168
  } catch {
107
169
  return null;
@@ -119,8 +181,13 @@ async function readReclaimOwner(lockPath) {
119
181
  try {
120
182
  const raw = JSON.parse(await fs.readFile(path.join(lockPath, RECLAIM_FILE), 'utf8'));
121
183
  const pid = Number(raw?.pid);
184
+ const pstart = Number(raw?.pstart);
122
185
  return Number.isInteger(pid) && pid > 0
123
- ? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
186
+ ? {
187
+ pid,
188
+ token: typeof raw?.token === 'string' ? raw.token : null,
189
+ pstart: Number.isFinite(pstart) && pstart > 0 ? pstart : null,
190
+ }
124
191
  : null;
125
192
  } catch {
126
193
  return null;
@@ -137,7 +204,7 @@ async function claimReclaim(lockPath, ownerToken, staleMs) {
137
204
  const handle = await fs.open(reclaimPath, 'wx');
138
205
  try {
139
206
  await handle.writeFile(
140
- `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
207
+ `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now(), pstart: PROCESS_START_MS })}\n`,
141
208
  'utf8',
142
209
  );
143
210
  } finally {
@@ -153,7 +220,7 @@ async function claimReclaim(lockPath, ownerToken, staleMs) {
153
220
  const stat = await fs.stat(reclaimPath);
154
221
  if (Date.now() - stat.mtimeMs > staleMs) {
155
222
  const claim = await readReclaimOwner(lockPath);
156
- if (!claim || !isPidAlive(claim.pid)) {
223
+ if (!claim || recordedProcessGone(claim)) {
157
224
  await fs.rm(reclaimPath, { force: true });
158
225
  }
159
226
  }
@@ -207,6 +274,56 @@ async function quarantineReclaim(lockPath, owner, ownerToken) {
207
274
  // a pid probe cannot prove, so the module tracks them itself (same as withFileLock).
208
275
  const inProcessLockHolders = new Map();
209
276
 
277
+ // --- TASK-004: dropped-update journal -------------------------------------------
278
+ // Fail-closed means a skipped mutation is LOST — the journal makes that loss
279
+ // explicit and auditable instead of a silent unlocked write. One JSONL record per
280
+ // drop, appended under a journal-local async lock (the main lock is unavailable —
281
+ // that is why this path runs). Bounded: a full journal applies backpressure and
282
+ // rejects the record rather than rewriting history, same as the ledger journal.
283
+ // Shared by both withFileLock twins (token-utils.mjs and src/core/fileOps.js) so
284
+ // the drop contract can never diverge between the protocol copies.
285
+ const LOCK_DROP_JOURNAL_MAX_RECORDS = 128;
286
+ const LOCK_DROP_JOURNAL_BUDGET_MS = 400;
287
+
288
+ export function lockDropJournalPath(filePath) {
289
+ return `${filePath}.lock-drops.jsonl`;
290
+ }
291
+
292
+ export async function journalDroppedLockMutation(filePath, { reason, waitedMs }) {
293
+ const journalPath = lockDropJournalPath(filePath);
294
+ const record = {
295
+ v: 1,
296
+ ts: Date.now(),
297
+ pid: process.pid,
298
+ file: path.basename(filePath),
299
+ reason,
300
+ waitedMs: Math.max(0, Math.round(waitedMs)),
301
+ };
302
+ try {
303
+ const outcome = await withAsyncLock(
304
+ journalPath,
305
+ { deadlineMs: LOCK_DROP_JOURNAL_BUDGET_MS },
306
+ async () => {
307
+ let existing = null;
308
+ try {
309
+ existing = await fs.readFile(journalPath, 'utf8');
310
+ } catch (error) {
311
+ if (error?.code !== 'ENOENT') return false;
312
+ }
313
+ const lines = existing === null
314
+ ? []
315
+ : existing.split('\n').filter((line) => line.trim());
316
+ if (lines.length >= LOCK_DROP_JOURNAL_MAX_RECORDS) return false;
317
+ await fs.appendFile(journalPath, `${JSON.stringify(record)}\n`, 'utf8');
318
+ return true;
319
+ },
320
+ );
321
+ return outcome?.ok === true && outcome.value === true;
322
+ } catch {
323
+ return false; // journaling is best-effort; never resurrect the mutation over it
324
+ }
325
+ }
326
+
210
327
  /**
211
328
  * Serialize a mutation of a shared state file across hook processes and concurrent
212
329
  * async flows. Protocol-compatible with token-utils withFileLock (`<file>.lock`
@@ -250,7 +367,7 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
250
367
  try {
251
368
  await fs.writeFile(
252
369
  path.join(lockPath, 'owner'),
253
- `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
370
+ `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now(), pstart: PROCESS_START_MS })}\n`,
254
371
  'utf8',
255
372
  );
256
373
  } catch {
@@ -281,15 +398,32 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
281
398
  // Someone holds the lock. Reclaim it only after the stale threshold AND only when
282
399
  // the holder is provably gone — stealing a live holder reintroduces the exact
283
400
  // interleaved-write race this lock exists to prevent.
401
+ //
402
+ // RC-4: the effective stale threshold for a budgeted waiter is
403
+ // min(staleMs, its remaining budget) — a waiter that cannot outlast the full
404
+ // LOCK_STALE_MS still reclaims a genuinely orphaned lock instead of freezing the
405
+ // liveness breakers behind it. A zero/negative remaining budget keeps the full
406
+ // threshold (a no-wait caller never steals). A lock whose recorded owner is a
407
+ // LIVE process is never reclaimed early; "provably gone" includes a recycled pid
408
+ // (stamped pstart no longer matches the running process).
284
409
  let reclaimed = false;
285
410
  try {
286
411
  const stat = await fs.stat(lockPath);
287
- if (Date.now() - stat.mtimeMs > stale) {
412
+ const ageMs = Date.now() - stat.mtimeMs;
413
+ const remainingMs = budget - (Date.now() - startedAt);
414
+ const effectiveStaleMs = remainingMs > 0 ? Math.min(stale, remainingMs) : stale;
415
+ if (ageMs > effectiveStaleMs) {
288
416
  const owner = await readLockOwner(lockPath);
289
417
  const liveInProcess = inProcessLockHolders.has(lockPath);
290
- const reclaimable = !owner || owner.pid === process.pid
291
- ? !liveInProcess
292
- : !isPidAlive(owner.pid);
418
+ // An ownerless lock may be a holder mid-stamp — only the FULL stale
419
+ // threshold proves abandonment there. A stamped owner provably gone
420
+ // (dead pid, recycled pid, or a same-pid record with no live in-process
421
+ // holder) is reclaimable at the budgeted threshold.
422
+ const reclaimable = !owner
423
+ ? (!liveInProcess && ageMs > stale)
424
+ : owner.pid === process.pid
425
+ ? !liveInProcess
426
+ : recordedProcessGone(owner);
293
427
  if (reclaimable && await claimReclaim(lockPath, ownerToken, stale)) {
294
428
  // Detach and clean only the generation that was validated. The atomic rename
295
429
  // makes this safe even when another process acquires lockPath immediately
@@ -1398,13 +1398,16 @@ async function runCli() {
1398
1398
  routeSummary: routeState?.routeSummary ?? null,
1399
1399
  }, sessionConfig);
1400
1400
 
1401
- if (nextState.phase === 'hard') {
1401
+ // TASK-004: withFileLock is fail-closed — a contended pressure lock skips the
1402
+ // mutation and resolves undefined (journaled). Skip the advisory notice too;
1403
+ // the next prompt re-evaluates pressure from the last committed state.
1404
+ if (nextState?.phase === 'hard') {
1402
1405
  process.stdout.write(
1403
1406
  `[ukit-skill-router] Context pressure: hard (est ${nextState.estimatedTotalTokens} / soft ${nextState.softThreshold} / hard ${nextState.hardThreshold}). `
1404
1407
  + 'FIRST THING IN YOUR REPLY, tell the user plainly — do not stay silent: "Context sắp đầy — hãy gõ /compact ngay bây giờ (run /compact now)". '
1405
1408
  + 'Then hold heavy work: no new investigations, subagents, or large edits until the user has compacted.\n',
1406
1409
  );
1407
- } else if (nextState.phase === 'soft') {
1410
+ } else if (nextState?.phase === 'soft') {
1408
1411
  process.stdout.write(
1409
1412
  `[ukit-skill-router] Context pressure: soft (est ${nextState.estimatedTotalTokens} / soft ${nextState.softThreshold} / hard ${nextState.hardThreshold}). `
1410
1413
  + 'Finish the current step, then tell the user to run /compact at the next natural pause instead of continuing indefinitely.\n',