@ngockhoale/ukit 3.0.9 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/bin/ukit +5 -9
  3. package/package.json +1 -1
  4. package/scripts/bench/memory-baseline.mjs +1 -1
  5. package/scripts/bench/memory-bench.mjs +1 -1
  6. package/scripts/bench/memory-canary.mjs +12 -2
  7. package/scripts/bench/recorder-overhead.mjs +1 -1
  8. package/scripts/bench/sqlite-spike.mjs +1 -1
  9. package/scripts/measure-decision-gateway.mjs +1 -1
  10. package/src/cli/commands/memory.js +3 -0
  11. package/src/cli/deadline.js +19 -0
  12. package/src/core/agentRuntime/eventStore.js +38 -10
  13. package/src/core/agentRuntime/supervisor.js +41 -9
  14. package/src/core/agentRuntime/vmEngine.js +33 -2
  15. package/src/core/codeintel/freshness.js +13 -9
  16. package/src/core/codeintel/retriever.js +25 -20
  17. package/src/core/fileOps.js +19 -4
  18. package/src/core/memory/memoryFlags.js +14 -6
  19. package/src/core/metadata.js +111 -81
  20. package/src/core/token/index.js +69 -28
  21. package/src/index/buildIndex.js +7 -0
  22. package/src/index/impactContext.js +14 -5
  23. package/src/render/instructionRenderer.js +71 -19
  24. package/template_project/.claude/hooks/auto-prune-bash.sh +60 -83
  25. package/template_project/.claude/hooks/block-dangerous.mjs +42 -11
  26. package/template_project/.claude/skills/docs-manager/init-project-docs.sh +59 -16
  27. package/template_project/.claude/skills/docx/scripts/document.py +31 -8
  28. package/template_project/.claude/skills/frontend-vue/composables/indexDBStore.js +26 -5
  29. package/template_project/.claude/skills/frontend-vue/composables/useRequest.js +24 -12
  30. package/template_project/.claude/skills/frontend-vue/composables/useSession.js +16 -11
  31. package/template_project/.claude/skills/frontend-vue/composables/useWebSocket.js +25 -2
  32. package/template_project/.claude/skills/pptx/scripts/rearrange.py +11 -2
  33. package/template_project/.claude/skills/root-cause-tracing/find-polluter.sh +20 -5
  34. package/template_project/.claude/skills/webapp-testing/scripts/with_server.py +37 -6
  35. package/template_project/.claude/ukit/index/cache-utils.mjs +25 -19
  36. package/template_project/.claude/ukit/index/impact-context.mjs +21 -13
  37. package/template_project/.claude/ukit/index/lib/index-core.mjs +96 -14
  38. package/template_project/.claude/ukit/index/query-index.mjs +13 -11
  39. package/template_project/.claude/ukit/index/resolve-context.mjs +12 -11
  40. package/template_project/.claude/ukit/index/route-task.mjs +44 -25
  41. package/template_project/.claude/ukit/index/triage.mjs +12 -11
  42. package/template_project/.claude/ukit/index/verify-context.mjs +12 -11
  43. package/template_project/.claude/ukit/runtime/async-lock.mjs +33 -0
  44. package/template_project/.claude/ukit/runtime/output-compression.mjs +9 -2
  45. package/template_project/.claude/ukit/runtime/resumable-run.mjs +18 -2
  46. 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
- if (lastRefreshMs !== null) {
77
- console.log(`[ukit:${logPrefix}] stale index refreshed (last=${new Date(lastRefreshMs).toISOString()})`);
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
- if (lastRefreshMs !== null) {
111
- console.log(`[ukit:${logPrefix}] stale index refreshed (last=${new Date(lastRefreshMs).toISOString()})`);
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
- await fs.mkdir(teeCacheDir, { recursive: true });
540
- await fs.writeFile(absolutePath, rawOutputText, 'utf8');
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 over and unlink.
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
- await fs.copyFile(tempPath, filePath);
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
- const nextDocument = normalizePromptCacheDocument({
486
- entries: [
487
- touchedEntry,
488
- ...cache.entries.slice(0, entryIndex),
489
- ...cache.entries.slice(entryIndex + 1),
490
- ],
491
- }, { maxEntries });
492
- await writeJson(runtimePaths.promptCachePath, nextDocument);
493
- return nextDocument.entries[0] ?? touchedEntry;
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
- const cache = normalizePromptCacheDocument(await readJson(runtimePaths.promptCachePath, null), { maxEntries });
504
- const nextDocument = normalizePromptCacheDocument({
505
- entries: [
506
- normalizedEntry,
507
- ...cache.entries.filter((existingEntry) => existingEntry.requestKey !== normalizedEntry.requestKey),
508
- ],
509
- }, { maxEntries });
510
- await writeJson(runtimePaths.promptCachePath, nextDocument);
511
- return nextDocument;
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) {