@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.
- package/CHANGELOG.md +54 -0
- package/package.json +1 -1
- package/src/context/detectProjectContext.js +5 -0
- package/src/core/codeintel/invalidation.js +4 -0
- package/src/core/diffPlan.js +60 -1
- package/src/core/fileOps.js +46 -119
- package/src/render/buildVariables.js +10 -0
- package/templates/.claude/agents/bug-debugger.md +1 -1
- package/templates/.claude/agents/feature-implementer.md +2 -2
- package/templates/.claude/commands/ukit/handoff-clear.md +11 -0
- package/templates/.claude/commands/ukit/handoff-create.md +1 -1
- package/templates/.claude/commands/ukit/handoff-fullstack.md +26 -1
- package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
- package/templates/.claude/commands/ukit/handoff-review.md +1 -1
- package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
- package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
- package/templates/.claude/hooks/reset-compact-pressure.sh +10 -0
- package/templates/.claude/hooks/skill-router.sh +15 -8
- package/templates/.claude/hooks/verification-guard.sh +3 -0
- package/templates/.claude/ukit/index/route-task.mjs +237 -32
- package/templates/.claude/ukit/index/stale-spec-check.mjs +38 -3
- package/templates/.claude/ukit/runtime/async-lock.mjs +240 -42
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +5 -2
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +217 -17
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +38 -4
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +131 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +35 -20
- package/templates/.claude/ukit/runtime/token-utils.mjs +37 -126
- package/templates/.codex/settings.json +1 -5
- package/templates/.omp/agents/bug-debugger.md +1 -1
- package/templates/.omp/agents/feature-implementer.md +2 -2
- package/templates/.omp/hooks/pre/ukit-bridge.js +216 -51
- package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
- package/templates/docs/AI_HANDOFF/RULES.md +6 -6
- 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
|
-
|
|
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
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* pid
|
|
119
|
-
* reclaim
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 |
|
|
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 |
|
|
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. "
|
|
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:
|