@ngockhoale/ukit 2.7.7 → 2.7.9

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 (35) hide show
  1. package/CHANGELOG.md +54 -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/diffPlan.js +60 -1
  6. package/src/core/fileOps.js +46 -119
  7. package/src/render/buildVariables.js +10 -0
  8. package/templates/.claude/agents/bug-debugger.md +1 -1
  9. package/templates/.claude/agents/feature-implementer.md +2 -2
  10. package/templates/.claude/commands/ukit/handoff-clear.md +11 -0
  11. package/templates/.claude/commands/ukit/handoff-create.md +1 -1
  12. package/templates/.claude/commands/ukit/handoff-fullstack.md +26 -1
  13. package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
  14. package/templates/.claude/commands/ukit/handoff-review.md +1 -1
  15. package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
  16. package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
  17. package/templates/.claude/hooks/reset-compact-pressure.sh +10 -0
  18. package/templates/.claude/hooks/skill-router.sh +15 -8
  19. package/templates/.claude/hooks/verification-guard.sh +3 -0
  20. package/templates/.claude/ukit/index/route-task.mjs +237 -32
  21. package/templates/.claude/ukit/index/stale-spec-check.mjs +38 -3
  22. package/templates/.claude/ukit/runtime/async-lock.mjs +240 -42
  23. package/templates/.claude/ukit/runtime/compact-threshold.mjs +5 -2
  24. package/templates/.claude/ukit/runtime/execution-ledger.mjs +217 -17
  25. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +38 -4
  26. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +131 -0
  27. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +35 -20
  28. package/templates/.claude/ukit/runtime/token-utils.mjs +37 -126
  29. package/templates/.codex/settings.json +1 -5
  30. package/templates/.omp/agents/bug-debugger.md +1 -1
  31. package/templates/.omp/agents/feature-implementer.md +2 -2
  32. package/templates/.omp/hooks/pre/ukit-bridge.js +216 -51
  33. package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
  34. package/templates/docs/AI_HANDOFF/RULES.md +6 -6
  35. package/templates/ukit/storage/config.json +2 -2
@@ -3,6 +3,8 @@ import crypto from 'node:crypto';
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
 
6
+ import { journalDroppedLockMutation, withAsyncLock } from './async-lock.mjs';
7
+
6
8
  export const DEFAULT_PROMPT_CACHE_MAX_ENTRIES = 20;
7
9
  export const DEFAULT_COMPACT_HISTORY_MAX_ENTRIES = 50;
8
10
  const DEFAULT_MAX_COMPACT_ANCHORS = 3;
@@ -60,7 +62,17 @@ export async function writeJson(filePath, value) {
60
62
  const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
61
63
  try {
62
64
  await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
63
- await fs.rename(tempPath, filePath);
65
+ try {
66
+ await fs.rename(tempPath, filePath);
67
+ } catch (renameError) {
68
+ // EXDEV: the tmp file and the destination sit on different mounts (union
69
+ // mounts, per-dir bind mounts, tmpfs overlays), so rename cannot link them.
70
+ // The payload is already fully written — copy it over and unlink the tmp.
71
+ // Less atomic than rename, but the update must not be silently lost.
72
+ if (renameError?.code !== 'EXDEV') throw renameError;
73
+ await fs.copyFile(tempPath, filePath);
74
+ await fs.rm(tempPath, { force: true });
75
+ }
64
76
  } catch (error) {
65
77
  try {
66
78
  await fs.rm(tempPath, { force: true });
@@ -74,140 +86,39 @@ export async function writeJson(filePath, value) {
74
86
  const LOCK_STALE_MS = 10_000;
75
87
  const LOCK_MAX_WAIT_MS = 5_000;
76
88
 
77
- function lockBackoffDelayMs() {
78
- return 3 + Math.floor(Math.random() * 9);
79
- }
80
-
81
- function sleep(ms) {
82
- return new Promise((resolve) => setTimeout(resolve, ms));
83
- }
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
-
111
89
  /**
112
90
  * Serialize read-modify-write mutations of a shared state file — across processes
113
91
  * (hook invocations run as separate node processes) and across concurrent async
114
92
  * flows in one process (parallel subagents). The lock is a directory created next
115
93
  * to the target file: `mkdir` is atomic, so exactly one caller can create it.
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.
122
- * Liveness wins over strictness: if the lock cannot be acquired within maxWaitMs
123
- * the callback runs anyway (the pre-lock behaviour) — these state files are
124
- * advisory caches, and losing an update beats freezing a hook mid-flight.
125
- * Protocol-compatible with src/core/fileOps.js withFileLock (same `<file>.lock`
126
- * path and owner-file format), so CLI processes and hook processes serialize
127
- * against each other.
94
+ *
95
+ * The entire lock protocol lives in async-lock.mjs (TASK-004 fix round 1): owner
96
+ * stamping (pid + token + pstart), recycled-pid detection via recordedProcessGone,
97
+ * claim+quarantine stale reclaim, and verified release exist exactly once there —
98
+ * this function and the src/core/fileOps.js twin both delegate to it so the two
99
+ * protocol copies can never drift apart again.
100
+ *
101
+ * FAIL-CLOSED (TASK-004, unified with async-lock/ledger policy): if the lock cannot
102
+ * be acquired within maxWaitMs the callback is SKIPPED — never run unlocked — and
103
+ * the drop is journaled to `<file>.lock-drops.jsonl`. These state files are
104
+ * advisory caches: losing an update was already the accepted outcome of the old
105
+ * fail-open race; now it is explicit and journaled instead of a silent torn write.
128
106
  * @param {string} filePath - state file the mutation targets (lock lives beside it)
129
107
  * @param {() => Promise<*>} fn - critical section; its result is returned
130
- * @returns {Promise<*>} whatever fn resolves with
108
+ * @returns {Promise<*|undefined>} whatever fn resolves with, or undefined when the
109
+ * lock wait expired and the mutation was skipped (journaled)
131
110
  */
132
111
  export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
133
- const lockPath = `${filePath}.lock`;
134
- const startedAt = Date.now();
135
- const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
136
- let locked = false;
137
- let ownerStamped = false;
138
-
139
- while (!locked) {
140
- try {
141
- await fs.mkdir(path.dirname(lockPath), { recursive: true });
142
- await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
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
- }
155
- break;
156
- } catch (error) {
157
- if (error?.code !== 'EEXIST') throw error;
158
- }
159
-
160
- // Someone holds the lock. Reclaim it only when the holder is provably gone.
161
- try {
162
- const stat = await fs.stat(lockPath);
163
- if (Date.now() - stat.mtimeMs > staleMs) {
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
- }
177
- }
178
- } catch (statError) {
179
- // BUG-C22-16: a persistent stat error (EPERM/ENOTDIR/EIO on a failing
180
- // mount, or ELOOP/ENOENT on a dangling symlink where mkdir still reports
181
- // EEXIST) must not busy-spin — a bare `continue` skipped both the
182
- // maxWait break and the backoff sleep, looping mkdir→stat→throw forever.
183
- if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
184
- if (statError?.code === 'ENOENT') continue; // lock vanished — retry immediately
185
- await sleep(lockBackoffDelayMs());
186
- continue;
187
- }
188
-
189
- if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
190
- await sleep(lockBackoffDelayMs());
191
- }
192
-
193
- try {
194
- return await fn();
195
- } finally {
196
- if (locked) {
197
- try {
198
- // Remove the lock only if THIS acquisition still owns it: after a stale reclaim
199
- // another holder may already own the dir, and deleting it would unlock their
200
- // critical section for a third waiter.
201
- const current = ownerStamped ? await readLockOwner(lockPath) : null;
202
- if (current && current.token === ownerToken) {
203
- await fs.rm(lockPath, { recursive: true, force: true });
204
- }
205
- if (inProcessLockHolders.get(lockPath) === ownerToken) inProcessLockHolders.delete(lockPath);
206
- } catch {
207
- // best-effort release; a stale lock is reclaimed by the next waiter
208
- }
209
- }
210
- }
112
+ const outcome = await withAsyncLock(filePath, { deadlineMs: maxWaitMs, staleMs }, fn);
113
+ if (outcome?.ok === true) return outcome.value;
114
+ // Fail closed: the mutation is dropped, never run unlocked. The drop is
115
+ // journaled so the lost update is auditable; callers treat undefined as
116
+ // "update skipped" (they already tolerated losing it silently).
117
+ await journalDroppedLockMutation(filePath, {
118
+ reason: 'lock-wait-expired',
119
+ waitedMs: outcome?.waitedMs ?? maxWaitMs,
120
+ });
121
+ return undefined;
211
122
  }
212
123
 
213
124
  export function buildCompactMachineKey(prefix, payload = {}) {
@@ -358,10 +358,6 @@
358
358
  "non-trivial": "full test + lint + typecheck"
359
359
  },
360
360
  "verification": {
361
- "requiredBeforeCompletion": [
362
- "{{runtime.packageManager}} test",
363
- "{{runtime.packageManager}} lint",
364
- "{{runtime.packageManager}} typecheck"
365
- ]
361
+ "requiredBeforeCompletion": {{verification.requiredBeforeCompletion}}
366
362
  }
367
363
  }
@@ -49,7 +49,7 @@ Systematic debugging — understand before fixing.
49
49
 
50
50
  ```
51
51
  STATUS: DONE | BLOCKED | PARTIAL
52
- EXECUTOR_TOOL: [claude-code | kilo-code | codex | other]
52
+ EXECUTOR_TOOL: [claude-code | codex | omp | other]
53
53
  EXECUTOR_MODEL: [exact model name you are running as. "unknown" if you cannot tell.]
54
54
  EXECUTOR_SUBAGENT: [subagent name within your host, if any, else "-"]
55
55
  SUMMARY: [1-2 sentences — root cause and fix]
@@ -71,9 +71,9 @@ and even then, report it, don't ask about it.
71
71
 
72
72
  ```
73
73
  STATUS: DONE | BLOCKED | PARTIAL
74
- EXECUTOR_TOOL: [claude-code | kilo-code | codex | other]
74
+ EXECUTOR_TOOL: [claude-code | codex | omp | other]
75
75
  EXECUTOR_MODEL: [exact model name you are running as — e.g. unic-code, claude-sonnet-4-5, gpt-5-mini. If you truly cannot tell, write "unknown" — reviewer treats unknown as suspicious and asks the human to confirm.]
76
- EXECUTOR_SUBAGENT: [name of the subagent you are, if your host has multiple — e.g. "Kilo:code", "Claude:feature-implementer". Otherwise "-".]
76
+ EXECUTOR_SUBAGENT: [name of the subagent you are, if your host has multiple — e.g. "Claude:feature-implementer", "omp:task". Otherwise "-".]
77
77
  SUMMARY: [1-2 sentences of what was implemented]
78
78
  TEST_PLAN_FOLLOWED: [task §4 / inline / N/A — reason]
79
79
  FILES_CHANGED: