@ngockhoale/ukit 2.4.2 → 2.4.3
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/manifests/platform.full.yaml +19 -111
- package/package.json +2 -1
- package/scripts/index/refresh-index.mjs +47 -22
- package/src/core/compact/threshold.js +36 -6
- package/src/diagnostics/classifyHang.js +246 -0
- package/src/index/buildIndex.js +1033 -62
- package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
- package/templates/.claude/hooks/block-dangerous.sh +31 -5
- package/templates/.claude/hooks/completion-gate.sh +51 -10
- package/templates/.claude/hooks/compress-output.sh +38 -6
- package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
- package/templates/.claude/hooks/context-window-guard.sh +128 -18
- package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
- package/templates/.claude/hooks/handoff-resume.sh +31 -5
- package/templates/.claude/hooks/post-edit-verify.sh +31 -5
- package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
- package/templates/.claude/hooks/protect-files.sh +31 -5
- package/templates/.claude/hooks/record-execution.sh +31 -5
- package/templates/.claude/hooks/sensitive-data-guard.sh +44 -8
- package/templates/.claude/hooks/skill-router.sh +31 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
- package/templates/.claude/hooks/task-watchdog.sh +108 -123
- package/templates/.claude/hooks/verification-guard.sh +107 -112
- package/templates/.claude/hooks/vision-router.sh +49 -13
- package/templates/.claude/settings.json +0 -5
- package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
- package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
- package/templates/.claude/ukit/index/route-task.mjs +610 -4
- package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
- package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
- package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
- package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
- package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import crypto from 'node:crypto';
|
|
5
6
|
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
8
|
import * as indexCore from './lib/index-core.mjs';
|
|
8
9
|
import {
|
|
9
10
|
DEFAULT_RECENT_CACHE_MAX_ENTRIES,
|
|
@@ -191,6 +192,583 @@ async function resolveVisionLane({
|
|
|
191
192
|
};
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
// --- TASK-030: budgeted, fingerprint-cached router entry (routeTask) -------------------------
|
|
196
|
+
// The router's prompt/tool hot path used to re-pay its heaviest unchanged-input reads on
|
|
197
|
+
// every call: the skill-metadata stats, config parse, index staleness enumeration (one full
|
|
198
|
+
// git enumeration per route) and the finalize pipeline. routeTask() is the bounded library
|
|
199
|
+
// entry: it fingerprints every routing-relevant input with cheap bounded metadata reads,
|
|
200
|
+
// returns the cached route on a hit, and on a miss recomputes under one top-level
|
|
201
|
+
// deadline/signal, degrading to a safe reduced route instead of failing the hook.
|
|
202
|
+
|
|
203
|
+
export const ROUTE_HOT_CACHE_FILENAME = 'route-hot-cache.json';
|
|
204
|
+
|
|
205
|
+
function resolveDefaultRouteDeadlineMs() {
|
|
206
|
+
// Explicit router deadline wins; otherwise derive from the wrapper's own watchdog
|
|
207
|
+
// (skill-router.sh self-exits at UKIT_HOOK_DEADLINE_MS) and leave it exit headroom.
|
|
208
|
+
const explicit = Number.parseInt(process.env.UKIT_ROUTE_DEADLINE_MS ?? '', 10);
|
|
209
|
+
if (Number.isFinite(explicit) && explicit > 0) {
|
|
210
|
+
return explicit;
|
|
211
|
+
}
|
|
212
|
+
const hookDeadline = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS ?? '', 10);
|
|
213
|
+
if (Number.isFinite(hookDeadline) && hookDeadline > 800) {
|
|
214
|
+
return hookDeadline - 400;
|
|
215
|
+
}
|
|
216
|
+
return 2500;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const DEFAULT_ROUTE_DEADLINE_MS = resolveDefaultRouteDeadlineMs();
|
|
220
|
+
|
|
221
|
+
// Max I/O ops (stat/readFile/readdir) per bounded scan phase. The fingerprint scan is
|
|
222
|
+
// deterministic and ordered, so truncation at the cap produces a stable key on both the
|
|
223
|
+
// write and the lookup side. 64 comfortably covers the ~54-op scan for the shipped catalog.
|
|
224
|
+
const DEFAULT_ROUTE_IO_BUDGET = 64;
|
|
225
|
+
|
|
226
|
+
// Bounded slice of the memory directory scanned for fingerprint metadata.
|
|
227
|
+
const ROUTE_MEMORY_SCAN_ENTRY_LIMIT = 16;
|
|
228
|
+
|
|
229
|
+
function createRouteBudget({ signal = null, deadlineMs = null } = {}) {
|
|
230
|
+
const startedAt = Date.now();
|
|
231
|
+
const effectiveDeadlineMs = Number.isFinite(deadlineMs) && deadlineMs > 0
|
|
232
|
+
? deadlineMs
|
|
233
|
+
: DEFAULT_ROUTE_DEADLINE_MS;
|
|
234
|
+
const deadline = startedAt + effectiveDeadlineMs;
|
|
235
|
+
return {
|
|
236
|
+
signal,
|
|
237
|
+
deadlineMs: effectiveDeadlineMs,
|
|
238
|
+
startedAt,
|
|
239
|
+
deadline,
|
|
240
|
+
ioReads: 0,
|
|
241
|
+
expired: () => Boolean(signal?.aborted) || Date.now() >= deadline,
|
|
242
|
+
expiredReason: () => (signal?.aborted ? 'signal:aborted' : 'deadline'),
|
|
243
|
+
remainingMs: () => Math.max(0, deadline - Date.now()),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Caller-supplied cache adapters are outside the router's control and cannot be assumed to
|
|
249
|
+
* settle. Keep their promises observed (so a later rejection is harmless), but never let them
|
|
250
|
+
* extend the route's one top-level budget. A timeout only abandons the adapter operation; it
|
|
251
|
+
* cannot cancel arbitrary adapter code, so no value from a late settlement is used.
|
|
252
|
+
*/
|
|
253
|
+
function awaitBoundedRouteCacheOperation(operation, budget, operationName) {
|
|
254
|
+
if (budget.expired()) {
|
|
255
|
+
return Promise.resolve({
|
|
256
|
+
timedOut: true,
|
|
257
|
+
reason: budget.expiredReason(),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const remainingMs = budget.remainingMs();
|
|
262
|
+
if (remainingMs <= 0) {
|
|
263
|
+
return Promise.resolve({ timedOut: true, reason: 'deadline' });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return new Promise((resolve) => {
|
|
267
|
+
let settled = false;
|
|
268
|
+
let timer = null;
|
|
269
|
+
|
|
270
|
+
const timeoutResult = () => ({
|
|
271
|
+
timedOut: true,
|
|
272
|
+
reason: budget.expiredReason() === 'signal:aborted'
|
|
273
|
+
? 'signal:aborted'
|
|
274
|
+
: `deadline:${operationName}`,
|
|
275
|
+
});
|
|
276
|
+
const cleanup = () => {
|
|
277
|
+
if (timer !== null) {
|
|
278
|
+
clearTimeout(timer);
|
|
279
|
+
}
|
|
280
|
+
budget.signal?.removeEventListener?.('abort', onAbort);
|
|
281
|
+
};
|
|
282
|
+
const settle = (result) => {
|
|
283
|
+
if (settled) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
settled = true;
|
|
287
|
+
const finalResult = result.timedOut || budget.expired()
|
|
288
|
+
? (result.timedOut ? result : timeoutResult())
|
|
289
|
+
: result;
|
|
290
|
+
cleanup();
|
|
291
|
+
resolve(finalResult);
|
|
292
|
+
};
|
|
293
|
+
const onAbort = () => settle({ timedOut: true, reason: 'signal:aborted' });
|
|
294
|
+
|
|
295
|
+
timer = setTimeout(() => settle(timeoutResult()), remainingMs);
|
|
296
|
+
if (budget.signal) {
|
|
297
|
+
if (budget.signal.aborted) {
|
|
298
|
+
onAbort();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
budget.signal.addEventListener('abort', onAbort, { once: true });
|
|
302
|
+
if (budget.signal.aborted) {
|
|
303
|
+
onAbort();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Promise.resolve().then() also turns a synchronous adapter throw into an observed rejection.
|
|
309
|
+
Promise.resolve()
|
|
310
|
+
.then(operation)
|
|
311
|
+
.then(
|
|
312
|
+
(value) => settle({ value }),
|
|
313
|
+
(error) => settle({ error }),
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function countRouteIo(budget, promise) {
|
|
319
|
+
budget.ioReads += 1;
|
|
320
|
+
return promise;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function statOrNothing(filePath) {
|
|
324
|
+
try {
|
|
325
|
+
return await fs.stat(filePath);
|
|
326
|
+
} catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function statFingerprintTuple(stat) {
|
|
332
|
+
return {
|
|
333
|
+
exists: Boolean(stat?.isFile?.()),
|
|
334
|
+
size: typeof stat?.size === 'number' ? stat.size : null,
|
|
335
|
+
mtimeMs: typeof stat?.mtimeMs === 'number' ? stat.mtimeMs : null,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function isIndexDeadlineError(error) {
|
|
340
|
+
return error?.name === 'IndexDiscoveryTimeoutError';
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Content fingerprint over EVERY input that affects routing (TASK-030 acceptance):
|
|
345
|
+
* prompt/command/target text, adapter/namespace/taskType/vision inputs, the route catalog
|
|
346
|
+
* composition, per-skill file metadata (stat tuples — config/skill changes force a miss),
|
|
347
|
+
* escalation config content, index generated-at, project verification metadata and the
|
|
348
|
+
* bounded memory-snapshot sources. Raw prompt bodies only ever feed the hashed key — they
|
|
349
|
+
* are never persisted, because the cached entry stores the opaque key plus the compact
|
|
350
|
+
* route state (prompt fields stripped, see buildRouteHotCacheState).
|
|
351
|
+
*/
|
|
352
|
+
async function buildRouteHotFingerprint({ rootDir, input, budget }) {
|
|
353
|
+
const absoluteRoot = path.resolve(rootDir);
|
|
354
|
+
const scanReadsAtStart = budget.ioReads;
|
|
355
|
+
const scanReads = () => budget.ioReads - scanReadsAtStart;
|
|
356
|
+
const payload = {
|
|
357
|
+
version: 1,
|
|
358
|
+
promptText: String(input.promptText || '').trim(),
|
|
359
|
+
lastExplicitUserPromptText: String(input.lastExplicitUserPromptText || '').trim(),
|
|
360
|
+
commandText: String(input.commandText || '').trim(),
|
|
361
|
+
targetFile: normalizeRelativeFile(absoluteRoot, input.targetFile),
|
|
362
|
+
taskType: String(input.taskType || '').trim(),
|
|
363
|
+
adapter: String(input.adapter || 'claude').trim() || 'claude',
|
|
364
|
+
commandNamespace: String(input.commandNamespace || '.claude').trim() || '.claude',
|
|
365
|
+
visionFlag: Boolean(input.visionFlag),
|
|
366
|
+
catalog: {
|
|
367
|
+
length: ROUTE_CATALOG.length,
|
|
368
|
+
digest: stableMachineDigest(ROUTE_CATALOG.map((entry) => ({
|
|
369
|
+
id: entry.id,
|
|
370
|
+
path: entry.path,
|
|
371
|
+
order: entry.order,
|
|
372
|
+
contextMode: entry.contextMode ?? 'indexed',
|
|
373
|
+
}))),
|
|
374
|
+
},
|
|
375
|
+
skills: [],
|
|
376
|
+
config: null,
|
|
377
|
+
indexGeneratedAtMs: null,
|
|
378
|
+
project: null,
|
|
379
|
+
memory: null,
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
for (const entry of ROUTE_CATALOG) {
|
|
383
|
+
if (budget.expired()) {
|
|
384
|
+
return { fingerprint: null, configJson: null, scanReads: scanReads() };
|
|
385
|
+
}
|
|
386
|
+
if (scanReads() >= DEFAULT_ROUTE_IO_BUDGET) {
|
|
387
|
+
payload.ioBudgetExhausted = true;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
const stat = await countRouteIo(budget, statOrNothing(path.join(absoluteRoot, entry.path)));
|
|
391
|
+
payload.skills.push({ path: entry.path, ...statFingerprintTuple(stat) });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!payload.ioBudgetExhausted && !budget.expired()) {
|
|
395
|
+
const configPath = path.join(absoluteRoot, '.ukit', 'storage', 'config.json');
|
|
396
|
+
const configStat = await countRouteIo(budget, statOrNothing(configPath));
|
|
397
|
+
payload.config = configStat
|
|
398
|
+
? { ...statFingerprintTuple(configStat), content: await countRouteIo(budget, readJson(configPath, null)) }
|
|
399
|
+
: null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (!payload.ioBudgetExhausted && !budget.expired()) {
|
|
403
|
+
// One bounded read of the index artifact timestamp; a rebuild (route 1) or a source
|
|
404
|
+
// change that triggers one (staleness check) always changes this value.
|
|
405
|
+
payload.indexGeneratedAtMs = await countRouteIo(
|
|
406
|
+
budget,
|
|
407
|
+
getIndexArtifactGeneratedAt({ rootDir: absoluteRoot }),
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!payload.ioBudgetExhausted && !budget.expired()) {
|
|
412
|
+
const projectVerification = await readProjectVerificationFingerprint(absoluteRoot);
|
|
413
|
+
budget.ioReads += projectVerification.entries.length;
|
|
414
|
+
payload.project = projectVerification.fingerprint;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!payload.ioBudgetExhausted && !budget.expired()) {
|
|
418
|
+
const memoryDirPath = path.join(absoluteRoot, '.ukit', 'storage', 'memory');
|
|
419
|
+
let dirents = null;
|
|
420
|
+
try {
|
|
421
|
+
dirents = await countRouteIo(budget, fs.readdir(memoryDirPath, { withFileTypes: true }));
|
|
422
|
+
} catch {
|
|
423
|
+
dirents = null;
|
|
424
|
+
}
|
|
425
|
+
const memoryEntries = [];
|
|
426
|
+
for (const dirent of (dirents ?? []).slice(0, ROUTE_MEMORY_SCAN_ENTRY_LIMIT)) {
|
|
427
|
+
if (scanReads() >= DEFAULT_ROUTE_IO_BUDGET) {
|
|
428
|
+
payload.ioBudgetExhausted = true;
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
const stat = await countRouteIo(budget, statOrNothing(path.join(memoryDirPath, dirent.name)));
|
|
432
|
+
memoryEntries.push({ name: dirent.name, ...statFingerprintTuple(stat) });
|
|
433
|
+
}
|
|
434
|
+
const outputHistoryStat = await countRouteIo(
|
|
435
|
+
budget,
|
|
436
|
+
statOrNothing(path.join(absoluteRoot, '.ukit', 'storage', 'cache', 'output-history.json')),
|
|
437
|
+
);
|
|
438
|
+
payload.memory = { entries: memoryEntries, outputHistory: statFingerprintTuple(outputHistoryStat) };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (budget.expired()) {
|
|
442
|
+
return { fingerprint: null, configJson: null, scanReads: scanReads() };
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
fingerprint: buildCompactMachineKey('route-hot-v1', payload),
|
|
446
|
+
configJson: payload.config?.content ?? null,
|
|
447
|
+
scanReads: scanReads(),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function createDefaultRouteHotCache({ rootDir }) {
|
|
452
|
+
const cachePath = path.join(path.resolve(rootDir), '.claude', 'ukit', ROUTE_HOT_CACHE_FILENAME);
|
|
453
|
+
return {
|
|
454
|
+
cachePath,
|
|
455
|
+
async read(requestKey) {
|
|
456
|
+
try {
|
|
457
|
+
return await readRecentCacheEntry(cachePath, requestKey, {
|
|
458
|
+
maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
|
|
459
|
+
});
|
|
460
|
+
} catch {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
async write(entry) {
|
|
465
|
+
try {
|
|
466
|
+
await writeRecentCacheEntry(cachePath, entry, {
|
|
467
|
+
maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
|
|
468
|
+
});
|
|
469
|
+
return true;
|
|
470
|
+
} catch {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function buildRouteHotCacheState(sharedState = {}) {
|
|
478
|
+
const state = { ...sharedState };
|
|
479
|
+
const routingContext = { ...(state.routingContext ?? {}) };
|
|
480
|
+
// Cache never stores prompt bodies or command text (TASK-030 acceptance): the opaque
|
|
481
|
+
// request key already covers them, and routeTask restores lastExplicitUserPromptText
|
|
482
|
+
// from the current input on a hit so printed output stays compatible with main().
|
|
483
|
+
delete routingContext.promptText;
|
|
484
|
+
delete routingContext.lastExplicitUserPromptText;
|
|
485
|
+
delete routingContext.commandText;
|
|
486
|
+
delete state.sessionId;
|
|
487
|
+
state.routingContext = routingContext;
|
|
488
|
+
return state;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function buildCachedRouteLookupState(cachedEntry, input) {
|
|
492
|
+
const cachedState = cachedEntry?.state;
|
|
493
|
+
if (!cachedState?.routeSummary || !Array.isArray(cachedState.activeSkills)) {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
const preservedPrompt = String(input.promptText || '').trim()
|
|
497
|
+
|| String(input.lastExplicitUserPromptText || '').trim();
|
|
498
|
+
return {
|
|
499
|
+
...cachedState,
|
|
500
|
+
routingContext: {
|
|
501
|
+
...cachedState.routingContext,
|
|
502
|
+
...(preservedPrompt ? { lastExplicitUserPromptText: preservedPrompt } : {}),
|
|
503
|
+
},
|
|
504
|
+
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
505
|
+
source: 'task-route',
|
|
506
|
+
ts: Date.now(),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function buildReducedRouteState({ input, reason }) {
|
|
511
|
+
// Safe reduced route: pure text-derived routing only — zero skills, no indexed context,
|
|
512
|
+
// no I/O. Callers can rely on the standard state shape; routeMeta.reduced marks it.
|
|
513
|
+
const absoluteRoot = path.resolve(input.rootDir ?? process.cwd());
|
|
514
|
+
const normalizedPrompt = String(input.promptText || '').trim();
|
|
515
|
+
const normalizedCommand = String(input.commandText || '').trim();
|
|
516
|
+
const normalizedTarget = normalizeRelativeFile(absoluteRoot, input.targetFile);
|
|
517
|
+
const intentMode = deriveIntentMode({
|
|
518
|
+
promptText: normalizedPrompt,
|
|
519
|
+
commandText: normalizedCommand,
|
|
520
|
+
targetFile: normalizedTarget,
|
|
521
|
+
});
|
|
522
|
+
const taskType = input.taskType ?? inferTaskType({
|
|
523
|
+
promptText: normalizedPrompt,
|
|
524
|
+
commandText: normalizedCommand,
|
|
525
|
+
selectedIds: [],
|
|
526
|
+
targetFile: normalizedTarget,
|
|
527
|
+
});
|
|
528
|
+
const executionScores = deriveExecutionScores({
|
|
529
|
+
promptText: normalizedPrompt,
|
|
530
|
+
commandText: normalizedCommand,
|
|
531
|
+
targetFile: normalizedTarget,
|
|
532
|
+
intentMode,
|
|
533
|
+
taskType,
|
|
534
|
+
});
|
|
535
|
+
const executionCandidates = buildExecutionModeCandidates({
|
|
536
|
+
promptText: normalizedPrompt,
|
|
537
|
+
commandText: normalizedCommand,
|
|
538
|
+
targetFile: normalizedTarget,
|
|
539
|
+
intentMode,
|
|
540
|
+
executionScores,
|
|
541
|
+
});
|
|
542
|
+
const executionMode = deriveExecutionMode({
|
|
543
|
+
promptText: normalizedPrompt,
|
|
544
|
+
commandText: normalizedCommand,
|
|
545
|
+
targetFile: normalizedTarget,
|
|
546
|
+
intentMode,
|
|
547
|
+
executionScores,
|
|
548
|
+
executionCandidates,
|
|
549
|
+
signalText: buildNormalizedRouteSignalText(normalizedPrompt, normalizedCommand),
|
|
550
|
+
});
|
|
551
|
+
const routingContext = {
|
|
552
|
+
adapter: String(input.adapter || 'claude').trim() || 'claude',
|
|
553
|
+
promptText: normalizedPrompt,
|
|
554
|
+
lastExplicitUserPromptText: normalizedPrompt || String(input.lastExplicitUserPromptText || '').trim(),
|
|
555
|
+
commandText: normalizedCommand,
|
|
556
|
+
targetFile: normalizedTarget,
|
|
557
|
+
contextIntent: deriveContextIntent({
|
|
558
|
+
promptText: normalizedPrompt,
|
|
559
|
+
commandText: normalizedCommand,
|
|
560
|
+
targetFile: normalizedTarget,
|
|
561
|
+
selectedIds: [],
|
|
562
|
+
}),
|
|
563
|
+
taskType,
|
|
564
|
+
intentMode,
|
|
565
|
+
executionScores,
|
|
566
|
+
executionCandidates,
|
|
567
|
+
executionMode,
|
|
568
|
+
visionLane: false,
|
|
569
|
+
visionReason: null,
|
|
570
|
+
visionModel: null,
|
|
571
|
+
visionAdvisory: null,
|
|
572
|
+
};
|
|
573
|
+
const route = {
|
|
574
|
+
activeSkills: [],
|
|
575
|
+
routingContext,
|
|
576
|
+
routeSummary: buildRouteSummary({ activeSkills: [], routingContext }),
|
|
577
|
+
};
|
|
578
|
+
return createSharedRouteState({
|
|
579
|
+
route,
|
|
580
|
+
source: 'task-route',
|
|
581
|
+
requestKey: null,
|
|
582
|
+
sessionId: input.sessionId ?? null,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Bounded, fingerprint-cached router entry (TASK-030).
|
|
588
|
+
* @param {object} input { rootDir, promptText, commandText, targetFile, taskType, adapter,
|
|
589
|
+
* commandNamespace, visionFlag, lastExplicitUserPromptText, sessionId }
|
|
590
|
+
* @param {object} options { signal?: AbortSignal, deadlineMs?: number, cache?: adapter }
|
|
591
|
+
* adapter: { read(requestKey) -> entry|null, write(entry) -> boolean, cachePath? }
|
|
592
|
+
* @returns {object} shared route state + cacheHit + bounded routeMeta diagnostics
|
|
593
|
+
* { cacheHit, reduced, reason, deadlineMs, elapsedMs, ioReads, ioBudget, cachePath }
|
|
594
|
+
*/
|
|
595
|
+
export async function routeTask(input = {}, { signal = null, deadlineMs = null, cache = null } = {}) {
|
|
596
|
+
const absoluteRoot = path.resolve(input.rootDir ?? process.cwd());
|
|
597
|
+
const budget = createRouteBudget({ signal, deadlineMs });
|
|
598
|
+
const hotCache = cache ?? createDefaultRouteHotCache({ rootDir: absoluteRoot });
|
|
599
|
+
const normalizedInput = { ...input, rootDir: absoluteRoot };
|
|
600
|
+
const baseMeta = {
|
|
601
|
+
cacheHit: false,
|
|
602
|
+
reduced: false,
|
|
603
|
+
reason: null,
|
|
604
|
+
deadlineMs: budget.deadlineMs,
|
|
605
|
+
elapsedMs: 0,
|
|
606
|
+
ioReads: 0,
|
|
607
|
+
ioBudget: DEFAULT_ROUTE_IO_BUDGET,
|
|
608
|
+
cachePath: hotCache.cachePath ?? null,
|
|
609
|
+
};
|
|
610
|
+
const finish = (state, metaOverrides = {}) => {
|
|
611
|
+
const routeMeta = {
|
|
612
|
+
...baseMeta,
|
|
613
|
+
...metaOverrides,
|
|
614
|
+
elapsedMs: Date.now() - budget.startedAt,
|
|
615
|
+
ioReads: budget.ioReads,
|
|
616
|
+
};
|
|
617
|
+
return { ...state, cacheHit: routeMeta.cacheHit, routeMeta };
|
|
618
|
+
};
|
|
619
|
+
const reduce = (reason) => finish(
|
|
620
|
+
buildReducedRouteState({ input: normalizedInput, reason }),
|
|
621
|
+
{ reduced: true, reason },
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
// Phase 1 — fingerprint + hot-cache lookup (the only I/O on the hit path).
|
|
625
|
+
if (budget.expired()) {
|
|
626
|
+
return reduce(budget.expiredReason());
|
|
627
|
+
}
|
|
628
|
+
const scan = await buildRouteHotFingerprint({ rootDir: absoluteRoot, input: normalizedInput, budget });
|
|
629
|
+
if (scan.fingerprint === null) {
|
|
630
|
+
return reduce(budget.expiredReason());
|
|
631
|
+
}
|
|
632
|
+
const cacheRead = await awaitBoundedRouteCacheOperation(
|
|
633
|
+
() => hotCache.read(scan.fingerprint),
|
|
634
|
+
budget,
|
|
635
|
+
'cache-read',
|
|
636
|
+
);
|
|
637
|
+
if (cacheRead.timedOut) {
|
|
638
|
+
return reduce(cacheRead.reason);
|
|
639
|
+
}
|
|
640
|
+
const cachedEntry = cacheRead.error ? null : cacheRead.value;
|
|
641
|
+
const cachedState = buildCachedRouteLookupState(cachedEntry, normalizedInput);
|
|
642
|
+
if (cachedState) {
|
|
643
|
+
return finish(cachedState, { cacheHit: true });
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Phase 2 — bounded full computation. Mirrors main()'s pipeline minus the shared-state,
|
|
647
|
+
// audit and helper-cache-seeding writes (main() stays the single writer for those).
|
|
648
|
+
const escalationConfig = scan.configJson
|
|
649
|
+
?? await readJson(path.join(absoluteRoot, '.ukit', 'storage', 'config.json'), null);
|
|
650
|
+
const preparedRoute = await prepareTaskRoute({
|
|
651
|
+
rootDir: absoluteRoot,
|
|
652
|
+
promptText: normalizedInput.promptText,
|
|
653
|
+
commandText: normalizedInput.commandText,
|
|
654
|
+
targetFile: normalizedInput.targetFile,
|
|
655
|
+
taskType: normalizedInput.taskType,
|
|
656
|
+
adapter: normalizedInput.adapter,
|
|
657
|
+
lastExplicitUserPromptText: normalizedInput.lastExplicitUserPromptText,
|
|
658
|
+
commandNamespace: normalizedInput.commandNamespace,
|
|
659
|
+
visionFlag: normalizedInput.visionFlag,
|
|
660
|
+
});
|
|
661
|
+
const useIndexedContext = shouldUseIndexedContext({
|
|
662
|
+
activeSkills: preparedRoute.activeSkills,
|
|
663
|
+
targetFile: preparedRoute.routingContext?.targetFile ?? null,
|
|
664
|
+
});
|
|
665
|
+
let indexGeneratedAtMs = null;
|
|
666
|
+
if (useIndexedContext) {
|
|
667
|
+
if (budget.expired()) {
|
|
668
|
+
return reduce(budget.expiredReason());
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
indexGeneratedAtMs = await ensureFreshIndex({
|
|
672
|
+
rootDir: absoluteRoot,
|
|
673
|
+
logPrefix: 'route',
|
|
674
|
+
signal: budget.signal,
|
|
675
|
+
deadlineMs: budget.remainingMs(),
|
|
676
|
+
discoverySnapshot: null,
|
|
677
|
+
});
|
|
678
|
+
} catch (error) {
|
|
679
|
+
if (isIndexDeadlineError(error) || budget.expired()) {
|
|
680
|
+
return reduce(budget.expiredReason() === 'signal:aborted' ? 'signal:aborted' : 'deadline:index-refresh');
|
|
681
|
+
}
|
|
682
|
+
throw error;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const projectVerification = useIndexedContext
|
|
686
|
+
? await readProjectVerificationFingerprint(absoluteRoot)
|
|
687
|
+
: null;
|
|
688
|
+
if (projectVerification) {
|
|
689
|
+
budget.ioReads += projectVerification.entries.length;
|
|
690
|
+
}
|
|
691
|
+
const recentOutputSnapshot = shouldIncludeRecentOutput({
|
|
692
|
+
activeSkills: preparedRoute.activeSkills,
|
|
693
|
+
routingContext: preparedRoute?.routingContext ?? {},
|
|
694
|
+
useIndexedContext,
|
|
695
|
+
})
|
|
696
|
+
? await buildRecentOutputSnapshot({ rootDir: absoluteRoot })
|
|
697
|
+
: null;
|
|
698
|
+
if (budget.expired()) {
|
|
699
|
+
return reduce(budget.expiredReason());
|
|
700
|
+
}
|
|
701
|
+
const previousContextSnapshot = shouldIncludePreviousContext({
|
|
702
|
+
routingContext: preparedRoute?.routingContext ?? {},
|
|
703
|
+
useIndexedContext,
|
|
704
|
+
})
|
|
705
|
+
? await buildPreviousContextSnapshot({
|
|
706
|
+
rootDir: absoluteRoot,
|
|
707
|
+
routingContext: preparedRoute?.routingContext ?? {},
|
|
708
|
+
})
|
|
709
|
+
: null;
|
|
710
|
+
if (budget.expired()) {
|
|
711
|
+
return reduce(budget.expiredReason());
|
|
712
|
+
}
|
|
713
|
+
const requestKey = buildRouteRequestKey({
|
|
714
|
+
commandNamespace: normalizedInput.commandNamespace,
|
|
715
|
+
indexGeneratedAtMs,
|
|
716
|
+
projectFingerprint: projectVerification?.fingerprint ?? null,
|
|
717
|
+
activeSkills: preparedRoute.activeSkills,
|
|
718
|
+
routingContext: preparedRoute.routingContext,
|
|
719
|
+
previousContextSnapshot,
|
|
720
|
+
recentOutputSnapshot,
|
|
721
|
+
});
|
|
722
|
+
const helperCacheSnapshot = await readHelperCacheSnapshot({
|
|
723
|
+
rootDir: absoluteRoot,
|
|
724
|
+
indexGeneratedAtMs,
|
|
725
|
+
projectFingerprint: projectVerification?.fingerprint ?? '',
|
|
726
|
+
routingContext: preparedRoute.routingContext,
|
|
727
|
+
useIndexedContext,
|
|
728
|
+
});
|
|
729
|
+
const route = await finalizeTaskRoute({
|
|
730
|
+
rootDir: absoluteRoot,
|
|
731
|
+
commandNamespace: normalizedInput.commandNamespace,
|
|
732
|
+
preparedRoute,
|
|
733
|
+
projectVerification,
|
|
734
|
+
cachedContextResult: helperCacheSnapshot.contextResult,
|
|
735
|
+
cachedVerificationPlan: helperCacheSnapshot.verificationPlan,
|
|
736
|
+
useIndexedContext,
|
|
737
|
+
previousContextSnapshot,
|
|
738
|
+
recentOutputSnapshot,
|
|
739
|
+
previousRouteSummary: null,
|
|
740
|
+
escalationConfig,
|
|
741
|
+
});
|
|
742
|
+
const sharedState = createSharedRouteState({
|
|
743
|
+
route,
|
|
744
|
+
source: 'task-route',
|
|
745
|
+
requestKey,
|
|
746
|
+
indexGeneratedAtMs,
|
|
747
|
+
sessionId: normalizedInput.sessionId ?? null,
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// Phase 3 — store under a POST-route fingerprint so the next identical call (whose own
|
|
751
|
+
// scan now sees the post-route filesystem state) hits. Never cached: reduced routes.
|
|
752
|
+
// A cache failure (malformed, unwritable, throwing adapter) must never fail the route.
|
|
753
|
+
const postScan = await buildRouteHotFingerprint({ rootDir: absoluteRoot, input: normalizedInput, budget });
|
|
754
|
+
if (postScan.fingerprint !== null && !budget.expired()) {
|
|
755
|
+
const cacheWrite = await awaitBoundedRouteCacheOperation(
|
|
756
|
+
() => hotCache.write({
|
|
757
|
+
requestKey: postScan.fingerprint,
|
|
758
|
+
updatedAt: Date.now(),
|
|
759
|
+
state: buildRouteHotCacheState(sharedState),
|
|
760
|
+
}),
|
|
761
|
+
budget,
|
|
762
|
+
'cache-write',
|
|
763
|
+
);
|
|
764
|
+
// Cache store failure or timeout is advisory only — the computed route is still returned.
|
|
765
|
+
// `cacheWrite.error` is intentionally ignored to preserve existing error swallowing.
|
|
766
|
+
}
|
|
767
|
+
return finish(sharedState, { cacheHit: false });
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export { DEFAULT_ROUTE_DEADLINE_MS, DEFAULT_ROUTE_IO_BUDGET };
|
|
771
|
+
|
|
194
772
|
async function main() {
|
|
195
773
|
const args = process.argv.slice(2);
|
|
196
774
|
const rootDir = getRootDir(args);
|
|
@@ -393,7 +971,14 @@ async function main() {
|
|
|
393
971
|
printRouteState(sharedState);
|
|
394
972
|
}
|
|
395
973
|
|
|
396
|
-
async function ensureFreshIndex({ rootDir, logPrefix }) {
|
|
974
|
+
async function ensureFreshIndex({ rootDir, logPrefix, signal = null, deadlineMs = null, discoverySnapshot = null } = {}) {
|
|
975
|
+
// TASK-030: the stale check and the rebuild are the router's heaviest unchanged-input
|
|
976
|
+
// reads (one full repository enumeration each), so both accept the caller's top-level
|
|
977
|
+
// deadline/signal. Omitted (main()'s CLI path) keeps the index-core defaults.
|
|
978
|
+
const discoveryOptions = {
|
|
979
|
+
...(signal ? { signal } : {}),
|
|
980
|
+
...(Number.isFinite(deadlineMs) && deadlineMs > 0 ? { deadlineMs } : {}),
|
|
981
|
+
};
|
|
397
982
|
const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir });
|
|
398
983
|
const stale = lastRefreshMs === null
|
|
399
984
|
? true
|
|
@@ -403,6 +988,7 @@ async function ensureFreshIndex({ rootDir, logPrefix }) {
|
|
|
403
988
|
maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
404
989
|
now: Date.now(),
|
|
405
990
|
generatedAtMs: lastRefreshMs,
|
|
991
|
+
...discoveryOptions,
|
|
406
992
|
})
|
|
407
993
|
: (Date.now() - lastRefreshMs) > DEFAULT_INDEX_CACHE_MAX_AGE_MS;
|
|
408
994
|
|
|
@@ -410,7 +996,7 @@ async function ensureFreshIndex({ rootDir, logPrefix }) {
|
|
|
410
996
|
return lastRefreshMs;
|
|
411
997
|
}
|
|
412
998
|
|
|
413
|
-
const summary = await buildCodeIndex({ rootDir });
|
|
999
|
+
const summary = await buildCodeIndex({ rootDir, ...discoveryOptions, discoverySnapshot });
|
|
414
1000
|
if (lastRefreshMs !== null) {
|
|
415
1001
|
console.log(`[ukit:${logPrefix}] stale index refreshed (last=${new Date(lastRefreshMs).toISOString()})`);
|
|
416
1002
|
} else {
|
|
@@ -3533,4 +4119,24 @@ function selectInlineSummaryTail(lines = []) {
|
|
|
3533
4119
|
return lines[lines.length - 1] ?? '';
|
|
3534
4120
|
}
|
|
3535
4121
|
|
|
3536
|
-
|
|
4122
|
+
// TASK-030: run the CLI flow only on a direct `node route-task.mjs` invocation. Library
|
|
4123
|
+
// importers (tests, future callers of routeTask) get the exports without main()'s
|
|
4124
|
+
// argv-driven side effects.
|
|
4125
|
+
function isDirectCliInvocation() {
|
|
4126
|
+
const entry = process.argv?.[1];
|
|
4127
|
+
if (!entry) {
|
|
4128
|
+
return false;
|
|
4129
|
+
}
|
|
4130
|
+
try {
|
|
4131
|
+
if (import.meta.url === pathToFileURL(entry).href) {
|
|
4132
|
+
return true;
|
|
4133
|
+
}
|
|
4134
|
+
return import.meta.url === pathToFileURL(realpathSync(entry)).href;
|
|
4135
|
+
} catch {
|
|
4136
|
+
return false;
|
|
4137
|
+
}
|
|
4138
|
+
}
|
|
4139
|
+
|
|
4140
|
+
if (isDirectCliInvocation()) {
|
|
4141
|
+
await main();
|
|
4142
|
+
}
|