@ngockhoale/ukit 3.0.10 → 3.0.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 +10 -0
- package/bin/ukit +5 -9
- package/package.json +1 -1
- package/scripts/bench/memory-baseline.mjs +1 -1
- package/scripts/bench/memory-bench.mjs +1 -1
- package/scripts/bench/memory-canary.mjs +12 -2
- package/scripts/bench/recorder-overhead.mjs +1 -1
- package/scripts/bench/sqlite-spike.mjs +1 -1
- package/scripts/measure-decision-gateway.mjs +1 -1
- package/src/cli/commands/memory.js +3 -0
- package/src/cli/deadline.js +19 -0
- package/src/core/agentRuntime/eventStore.js +38 -10
- package/src/core/agentRuntime/supervisor.js +41 -9
- package/src/core/agentRuntime/vmEngine.js +33 -2
- package/src/core/codeintel/freshness.js +13 -9
- package/src/core/codeintel/retriever.js +25 -20
- package/src/core/fileOps.js +19 -4
- package/src/core/memory/memoryFlags.js +14 -6
- package/src/core/metadata.js +111 -81
- package/src/core/token/index.js +69 -28
- package/src/index/buildIndex.js +7 -0
- package/src/index/impactContext.js +14 -5
- package/src/render/instructionRenderer.js +71 -19
- package/template_project/.claude/hooks/auto-prune-bash.sh +60 -83
- package/template_project/.claude/hooks/block-dangerous.mjs +42 -11
- package/template_project/.claude/skills/docs-manager/init-project-docs.sh +59 -16
- package/template_project/.claude/skills/docx/scripts/document.py +31 -8
- package/template_project/.claude/skills/frontend-vue/composables/indexDBStore.js +26 -5
- package/template_project/.claude/skills/frontend-vue/composables/useRequest.js +24 -12
- package/template_project/.claude/skills/frontend-vue/composables/useSession.js +16 -11
- package/template_project/.claude/skills/frontend-vue/composables/useWebSocket.js +25 -2
- package/template_project/.claude/skills/pptx/scripts/rearrange.py +11 -2
- package/template_project/.claude/skills/root-cause-tracing/find-polluter.sh +20 -5
- package/template_project/.claude/skills/webapp-testing/scripts/with_server.py +37 -6
- package/template_project/.claude/ukit/index/cache-utils.mjs +25 -19
- package/template_project/.claude/ukit/index/impact-context.mjs +21 -13
- package/template_project/.claude/ukit/index/lib/index-core.mjs +96 -14
- package/template_project/.claude/ukit/index/query-index.mjs +13 -11
- package/template_project/.claude/ukit/index/resolve-context.mjs +12 -11
- package/template_project/.claude/ukit/index/route-task.mjs +44 -25
- package/template_project/.claude/ukit/index/triage.mjs +12 -11
- package/template_project/.claude/ukit/index/verify-context.mjs +12 -11
- package/template_project/.claude/ukit/runtime/async-lock.mjs +33 -0
- package/template_project/.claude/ukit/runtime/output-compression.mjs +9 -2
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +18 -2
- package/template_project/.claude/ukit/runtime/token-utils.mjs +60 -18
|
@@ -22,7 +22,7 @@ const signature = collectPositionalArgs(args, ['--root']);
|
|
|
22
22
|
if (!signature) {
|
|
23
23
|
console.error('Usage: node .claude/ukit/index/triage.mjs "<error signature>"');
|
|
24
24
|
process.exitCode = 1;
|
|
25
|
-
} else {
|
|
25
|
+
} else try {
|
|
26
26
|
const indexGeneratedAtMs = await ensureFreshIndex({ rootDir, logPrefix: 'bug:triage' });
|
|
27
27
|
const cachePath = path.join(rootDir, '.claude', 'ukit', 'triage-cache.json');
|
|
28
28
|
const requestKey = buildTriageRequestKey({
|
|
@@ -55,7 +55,15 @@ if (!signature) {
|
|
|
55
55
|
console.log(`test: ${result.recommendedTestCommand}`);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
catch (error) {
|
|
59
|
+
console.error(error && error.message ? error.message : error);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
58
63
|
async function ensureFreshIndex({ rootDir, logPrefix }) {
|
|
64
|
+
if (typeof indexCore.ensureIndexFresh === 'function') {
|
|
65
|
+
return indexCore.ensureIndexFresh({ rootDir, logPrefix });
|
|
66
|
+
}
|
|
59
67
|
const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir });
|
|
60
68
|
const stale = lastRefreshMs === null
|
|
61
69
|
? true
|
|
@@ -67,17 +75,10 @@ async function ensureFreshIndex({ rootDir, logPrefix }) {
|
|
|
67
75
|
generatedAtMs: lastRefreshMs,
|
|
68
76
|
})
|
|
69
77
|
: (Date.now() - lastRefreshMs) > DEFAULT_INDEX_CACHE_MAX_AGE_MS;
|
|
70
|
-
|
|
71
|
-
if (!stale) {
|
|
72
|
-
return lastRefreshMs;
|
|
73
|
-
}
|
|
74
|
-
|
|
78
|
+
if (!stale) return lastRefreshMs;
|
|
75
79
|
const summary = await buildCodeIndex({ rootDir });
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
} else {
|
|
79
|
-
console.log(`[ukit:${logPrefix}] stale index refreshed (no previous cache timestamp)`);
|
|
80
|
-
}
|
|
80
|
+
// Prefer the build's own generatedAtMs; only re-read when the stubbed
|
|
81
|
+
// build didn't report one (keeps generatedAt call count at 1).
|
|
81
82
|
return Number.isFinite(summary?.generatedAtMs)
|
|
82
83
|
? summary.generatedAtMs
|
|
83
84
|
: getIndexArtifactGeneratedAt({ rootDir });
|
|
@@ -30,7 +30,7 @@ const effectiveIntent = deriveHelperIntent({ intent, targetFile });
|
|
|
30
30
|
if (!intent && !targetFile) {
|
|
31
31
|
console.error('Usage: node .claude/ukit/index/verify-context.mjs "<intent>" [--target <file>] [--type trivial|simple|non-trivial]');
|
|
32
32
|
process.exitCode = 1;
|
|
33
|
-
} else {
|
|
33
|
+
} else try {
|
|
34
34
|
const indexGeneratedAtMs = await ensureFreshIndex({
|
|
35
35
|
rootDir,
|
|
36
36
|
logPrefix: 'verify',
|
|
@@ -89,7 +89,15 @@ if (!intent && !targetFile) {
|
|
|
89
89
|
printVerificationPlan(plan);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
catch (error) {
|
|
93
|
+
console.error(error && error.message ? error.message : error);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
92
97
|
async function ensureFreshIndex({ rootDir, logPrefix }) {
|
|
98
|
+
if (typeof indexCore.ensureIndexFresh === 'function') {
|
|
99
|
+
return indexCore.ensureIndexFresh({ rootDir, logPrefix });
|
|
100
|
+
}
|
|
93
101
|
const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir });
|
|
94
102
|
const stale = lastRefreshMs === null
|
|
95
103
|
? true
|
|
@@ -101,17 +109,10 @@ async function ensureFreshIndex({ rootDir, logPrefix }) {
|
|
|
101
109
|
generatedAtMs: lastRefreshMs,
|
|
102
110
|
})
|
|
103
111
|
: (Date.now() - lastRefreshMs) > DEFAULT_INDEX_CACHE_MAX_AGE_MS;
|
|
104
|
-
|
|
105
|
-
if (!stale) {
|
|
106
|
-
return lastRefreshMs;
|
|
107
|
-
}
|
|
108
|
-
|
|
112
|
+
if (!stale) return lastRefreshMs;
|
|
109
113
|
const summary = await buildCodeIndex({ rootDir });
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
} else {
|
|
113
|
-
console.log(`[ukit:${logPrefix}] stale index refreshed (no previous cache timestamp)`);
|
|
114
|
-
}
|
|
114
|
+
// Prefer the build's own generatedAtMs; only re-read when the stubbed
|
|
115
|
+
// build didn't report one (keeps generatedAt call count at 1).
|
|
115
116
|
return Number.isFinite(summary?.generatedAtMs)
|
|
116
117
|
? summary.generatedAtMs
|
|
117
118
|
: getIndexArtifactGeneratedAt({ rootDir });
|
|
@@ -323,6 +323,36 @@ async function quarantineReclaim(lockPath, owner, ownerToken, retry = withTransi
|
|
|
323
323
|
}
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
// C79-21: a process dying between quarantineReclaim's rename and rm strands a
|
|
327
|
+
// `<lock>.reclaim-<token>` sibling dir forever — the quarantine path carries no
|
|
328
|
+
// staleness record of its own, so nothing reclaims it later. Every acquire
|
|
329
|
+
// therefore sweeps THIS lock's orphaned quarantine dirs: one bounded readdir on
|
|
330
|
+
// the lock's parent dir removes `<basename>.reclaim-*` dirs older than staleMs.
|
|
331
|
+
// Fresh dirs are left alone — a live reaper may be between rename and rm on them.
|
|
332
|
+
// Best-effort: every failure is swallowed; the next acquire retries the sweep.
|
|
333
|
+
async function sweepStaleReclaims(lockPath, staleMs) {
|
|
334
|
+
const parentDir = path.dirname(lockPath);
|
|
335
|
+
const prefix = `${path.basename(lockPath)}.reclaim-`;
|
|
336
|
+
let entries;
|
|
337
|
+
try {
|
|
338
|
+
entries = await fs.readdir(parentDir, { withFileTypes: true });
|
|
339
|
+
} catch {
|
|
340
|
+
return; // missing or unreadable parent — nothing to sweep
|
|
341
|
+
}
|
|
342
|
+
for (const entry of entries) {
|
|
343
|
+
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
|
|
344
|
+
const orphanPath = path.join(parentDir, entry.name);
|
|
345
|
+
try {
|
|
346
|
+
const stat = await fs.stat(orphanPath);
|
|
347
|
+
if (Date.now() - stat.mtimeMs <= staleMs) continue;
|
|
348
|
+
await fs.rm(orphanPath, { recursive: true, force: true });
|
|
349
|
+
} catch {
|
|
350
|
+
// entry raced away or is unreapable — leave it for the next acquire
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
326
356
|
// In-process holder registry: same-pid holders are parallel async flows whose liveness
|
|
327
357
|
// a pid probe cannot prove, so the module tracks them itself (same as withFileLock).
|
|
328
358
|
const inProcessLockHolders = new Map();
|
|
@@ -422,6 +452,9 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
422
452
|
const releaseRetry = (op) => withTransientFsRetry(op, { deadlineMs: LOCK_RESERVE_MS });
|
|
423
453
|
|
|
424
454
|
while (true) {
|
|
455
|
+
// Reap stale `*.reclaim-*` quarantine dirs stranded by dead reapers. Cheap:
|
|
456
|
+
// one readdir per attempt, all failures swallowed (C79-21).
|
|
457
|
+
await sweepStaleReclaims(lockPath, stale);
|
|
425
458
|
try {
|
|
426
459
|
// The lock parent must exist before the atomic acquire — a first-ever run in a
|
|
427
460
|
// fresh project would otherwise fail mkdir with ENOENT.
|
|
@@ -536,8 +536,15 @@ async function persistRawOutput(projectRoot, {
|
|
|
536
536
|
exitCode,
|
|
537
537
|
});
|
|
538
538
|
|
|
539
|
-
|
|
540
|
-
|
|
539
|
+
try {
|
|
540
|
+
await fs.mkdir(teeCacheDir, { recursive: true });
|
|
541
|
+
await fs.writeFile(absolutePath, rawOutputText, 'utf8');
|
|
542
|
+
} catch {
|
|
543
|
+
// TASK-003 / FR-006: an unwritable tee dir (EACCES, ENOSPC, EEXIST on a
|
|
544
|
+
// non-dir, …) must degrade to "no raw file" — never reject the caller's
|
|
545
|
+
// compression result.
|
|
546
|
+
return { rawSaved: false, rawPath: '', rawBytes: 0 };
|
|
547
|
+
}
|
|
541
548
|
// Bounded, sampled tee/ prune (BUG-C21-07): advisory only — a sweep failure
|
|
542
549
|
// must never lose the preserved output this call just wrote. BUG-C23-10: a
|
|
543
550
|
// persistent failure (EACCES/ENOSPC) still surfaces as a bounded degrade.
|
|
@@ -266,9 +266,25 @@ async function writeJsonAtomic(filePath, value) {
|
|
|
266
266
|
try {
|
|
267
267
|
await fs.rename(tempPath, filePath);
|
|
268
268
|
} catch (renameError) {
|
|
269
|
-
// EXDEV: tmp and destination on different mounts — copy
|
|
269
|
+
// EXDEV: tmp and destination on different mounts — copy UP to a sibling
|
|
270
|
+
// temp on the destination's mount and publish with a second, same-layer
|
|
271
|
+
// rename. A direct copy onto the live record is the last resort only:
|
|
272
|
+
// it is non-atomic, and a crash mid-copy truncates the live record.
|
|
270
273
|
if (renameError?.code !== 'EXDEV') throw renameError;
|
|
271
|
-
|
|
274
|
+
const exdevPath = `${filePath}.exdev-${process.pid}-${Date.now()}-${atomicWriteCounter++}`;
|
|
275
|
+
try {
|
|
276
|
+
await fs.copyFile(tempPath, exdevPath);
|
|
277
|
+
try {
|
|
278
|
+
await fs.rename(exdevPath, filePath);
|
|
279
|
+
} catch (secondRenameError) {
|
|
280
|
+
// Union/bind mount where even same-dir rename is refused: the direct
|
|
281
|
+
// copy below is non-atomic, but the record must not be silently lost.
|
|
282
|
+
if (secondRenameError?.code !== 'EXDEV') throw secondRenameError;
|
|
283
|
+
await fs.copyFile(tempPath, filePath);
|
|
284
|
+
}
|
|
285
|
+
} finally {
|
|
286
|
+
await fs.rm(exdevPath, { force: true }).catch(() => {});
|
|
287
|
+
}
|
|
272
288
|
await fs.rm(tempPath, { force: true });
|
|
273
289
|
}
|
|
274
290
|
} catch (error) {
|
|
@@ -482,15 +482,49 @@ export async function readPromptCacheEntry(projectRoot, requestKey, { maxEntries
|
|
|
482
482
|
lastHitAt: Date.now(),
|
|
483
483
|
hitCount: (entry.hitCount ?? 0) + 1,
|
|
484
484
|
};
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
485
|
+
try {
|
|
486
|
+
// SPEC §5 FR-019: the touch's read-merge-write runs under the cache-file
|
|
487
|
+
// lock — concurrent hook processes used to merge over the same snapshot
|
|
488
|
+
// and drop hitCounts/entries. The lock RE-READS the document so the merge
|
|
489
|
+
// applies to the freshest state, and an entry a concurrent purge already
|
|
490
|
+
// dropped is never resurrected.
|
|
491
|
+
const locked = await withFileLock(runtimePaths.promptCachePath, async () => {
|
|
492
|
+
const lockedCache = normalizePromptCacheDocument(
|
|
493
|
+
await readJson(runtimePaths.promptCachePath, null),
|
|
494
|
+
{ maxEntries },
|
|
495
|
+
);
|
|
496
|
+
const lockedIndex = lockedCache.entries.findIndex(
|
|
497
|
+
(lockedEntry) => lockedEntry.requestKey === requestKey,
|
|
498
|
+
);
|
|
499
|
+
if (lockedIndex < 0) {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
const mergedEntry = {
|
|
503
|
+
...lockedCache.entries[lockedIndex],
|
|
504
|
+
updatedAt: Date.now(),
|
|
505
|
+
lastHitAt: Date.now(),
|
|
506
|
+
hitCount: (lockedCache.entries[lockedIndex].hitCount ?? 0) + 1,
|
|
507
|
+
};
|
|
508
|
+
const nextDocument = normalizePromptCacheDocument({
|
|
509
|
+
entries: [
|
|
510
|
+
mergedEntry,
|
|
511
|
+
...lockedCache.entries.slice(0, lockedIndex),
|
|
512
|
+
...lockedCache.entries.slice(lockedIndex + 1),
|
|
513
|
+
],
|
|
514
|
+
}, { maxEntries });
|
|
515
|
+
await writeJson(runtimePaths.promptCachePath, nextDocument);
|
|
516
|
+
return nextDocument.entries[0] ?? mergedEntry;
|
|
517
|
+
});
|
|
518
|
+
// Lock-busy (fail-closed skip, journaled) or entry-gone-under-lock →
|
|
519
|
+
// degrade to the in-memory touched entry, same as the failure path below.
|
|
520
|
+
return locked ?? touchedEntry;
|
|
521
|
+
} catch {
|
|
522
|
+
// SPEC §5 FR-009 (mirrors src/core/token/index.js): the touch-write is a
|
|
523
|
+
// side effect of a READ — a full/unwritable cache (EACCES, ENOSPC, ...)
|
|
524
|
+
// must never reject the read. Degrade to the in-memory touched entry; the
|
|
525
|
+
// persisted hitCount simply lags until a later write succeeds.
|
|
526
|
+
return touchedEntry;
|
|
527
|
+
}
|
|
494
528
|
}
|
|
495
529
|
|
|
496
530
|
export async function writePromptCacheEntry(projectRoot, entry, { maxEntries = DEFAULT_PROMPT_CACHE_MAX_ENTRIES } = {}) {
|
|
@@ -500,15 +534,23 @@ export async function writePromptCacheEntry(projectRoot, entry, { maxEntries = D
|
|
|
500
534
|
}
|
|
501
535
|
|
|
502
536
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
537
|
+
// SPEC §5 FR-019: the merge must re-read under the lock — merging over a
|
|
538
|
+
// pre-lock snapshot is the lost-update race this lock exists to close, and
|
|
539
|
+
// no code path may write the cache unlocked.
|
|
540
|
+
const lockedDocument = await withFileLock(runtimePaths.promptCachePath, async () => {
|
|
541
|
+
const cache = normalizePromptCacheDocument(await readJson(runtimePaths.promptCachePath, null), { maxEntries });
|
|
542
|
+
const nextDocument = normalizePromptCacheDocument({
|
|
543
|
+
entries: [
|
|
544
|
+
normalizedEntry,
|
|
545
|
+
...cache.entries.filter((existingEntry) => existingEntry.requestKey !== normalizedEntry.requestKey),
|
|
546
|
+
],
|
|
547
|
+
}, { maxEntries });
|
|
548
|
+
await writeJson(runtimePaths.promptCachePath, nextDocument);
|
|
549
|
+
return nextDocument;
|
|
550
|
+
});
|
|
551
|
+
// Lock-busy → the write was skipped (journaled); callers already tolerate a
|
|
552
|
+
// skipped cache update, so report null like the invalid-entry path.
|
|
553
|
+
return lockedDocument ?? null;
|
|
512
554
|
}
|
|
513
555
|
|
|
514
556
|
function normalizeCompactHistoryEntry(entry) {
|