@ngockhoale/ukit 2.3.9 → 2.3.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 +87 -0
- package/package.json +1 -1
- package/src/core/fileOps.js +61 -7
- package/src/index/taskRouting.js +35 -2
- package/templates/.claude/hooks/auto-allow-bash.sh +44 -7
- package/templates/.claude/hooks/auto-prune-bash.sh +44 -7
- package/templates/.claude/hooks/context-hardcap-gate.sh +18 -5
- package/templates/.claude/hooks/handoff-resume.sh +10 -1
- package/templates/.claude/hooks/reset-compact-pressure.sh +47 -7
- package/templates/.claude/hooks/skill-router.sh +213 -44
- package/templates/.claude/hooks/verification-guard.sh +55 -20
- package/templates/.claude/ukit/index/route-task.mjs +36 -3
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +50 -4
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +339 -62
- package/templates/.claude/ukit/runtime/token-utils.mjs +69 -6
- package/templates/.omp/hooks/pre/ukit-bridge.js +68 -16
|
@@ -121,6 +121,23 @@ const { pathToFileURL } = require('url');
|
|
|
121
121
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
function writeJsonFileAtomic(filePath, text) {
|
|
125
|
+
// tmp+rename: readers see old-or-new, never a torn/half file. A truncated
|
|
126
|
+
// skill-router-state.json parses as `{}` downstream and completion-gate.sh then
|
|
127
|
+
// releases a premature Stop with no message — the mid-run silent-stop class.
|
|
128
|
+
ensureDir(filePath);
|
|
129
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
130
|
+
try {
|
|
131
|
+
fs.writeFileSync(tempPath, text);
|
|
132
|
+
fs.renameSync(tempPath, filePath);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
try {
|
|
135
|
+
fs.rmSync(tempPath, { force: true });
|
|
136
|
+
} catch {}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
124
141
|
function defaultRuntimeConfig() {
|
|
125
142
|
return {
|
|
126
143
|
memory: {
|
|
@@ -544,7 +561,7 @@ const { pathToFileURL } = require('url');
|
|
|
544
561
|
routingContext = {},
|
|
545
562
|
previousContextFingerprint = null,
|
|
546
563
|
}) {
|
|
547
|
-
return buildCompactMachineKey('route-
|
|
564
|
+
return buildCompactMachineKey('route-v3', {
|
|
548
565
|
commandNamespace,
|
|
549
566
|
indexGeneratedAtMs,
|
|
550
567
|
projectFingerprint,
|
|
@@ -1577,8 +1594,10 @@ const { pathToFileURL } = require('url');
|
|
|
1577
1594
|
intentMode = null,
|
|
1578
1595
|
executionScores = {},
|
|
1579
1596
|
executionCandidates = null,
|
|
1597
|
+
signalText = '',
|
|
1580
1598
|
} = {}) {
|
|
1581
1599
|
const raw = `${promptText || ''}\n${commandText || ''}`.toLowerCase();
|
|
1600
|
+
const signalRaw = `${raw}\n${String(signalText || '').toLowerCase()}`;
|
|
1582
1601
|
const scores = executionScores;
|
|
1583
1602
|
const candidates = executionCandidates ?? buildExecutionModeCandidates({
|
|
1584
1603
|
promptText,
|
|
@@ -1591,15 +1610,45 @@ const { pathToFileURL } = require('url');
|
|
|
1591
1610
|
const strongImpactLead = scores.sharedRisk
|
|
1592
1611
|
&& (scores.impactSignal || /\b(check all affected|map all affected|across all affected)\b/.test(raw));
|
|
1593
1612
|
const boundedLocalBuildCandidate = scores.buildSignal && scores.boundedEditSignal && scores.explicitTarget && !scores.sharedRisk;
|
|
1613
|
+
const releaseVerificationContinuation = /\b(?:pending\s+diff[- ]review|self-install|verify\s+(?:consistency|release)|release\s+(?:verification|readiness))\b/.test(raw)
|
|
1614
|
+
&& /\b(?:release|tag|publish|push|report)\b/.test(raw);
|
|
1615
|
+
// A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
|
|
1616
|
+
// repository mutation the ledger could ever receipt. With no edit/review/debug/
|
|
1617
|
+
// build signal present, routing it to an investigation lane fabricates write debt
|
|
1618
|
+
// and the completion gate then demands an edit that cannot exist. Delivery-only
|
|
1619
|
+
// requests carry no completion contract instead.
|
|
1620
|
+
const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
|
|
1621
|
+
|| /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
|
|
1622
|
+
|| /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
|
|
1623
|
+
|| /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
|
|
1624
|
+
const deliveryOnlySignal = deliveryWordSignal
|
|
1625
|
+
&& scores.editCertainty === 0
|
|
1626
|
+
&& !scores.implementSignal
|
|
1627
|
+
&& !scores.reviewSignal
|
|
1628
|
+
&& !scores.debugSignal
|
|
1629
|
+
&& !scores.failureSignal
|
|
1630
|
+
&& !scores.impactSignal
|
|
1631
|
+
&& !scores.buildSignal
|
|
1632
|
+
&& !scores.directTransformSignal
|
|
1633
|
+
&& !scores.smallFixSignal
|
|
1634
|
+
&& !scores.sharedRisk
|
|
1635
|
+
&& !targetFile;
|
|
1594
1636
|
|
|
1595
1637
|
if (isInformationalPrompt({ promptText, commandText, scores })) {
|
|
1596
1638
|
return 'informational';
|
|
1597
1639
|
}
|
|
1598
1640
|
|
|
1599
|
-
if (
|
|
1641
|
+
if (
|
|
1642
|
+
releaseVerificationContinuation
|
|
1643
|
+
|| ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal)
|
|
1644
|
+
) {
|
|
1600
1645
|
return 'review-release';
|
|
1601
1646
|
}
|
|
1602
1647
|
|
|
1648
|
+
if (deliveryOnlySignal) {
|
|
1649
|
+
return 'informational';
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1603
1652
|
if (strongImpactLead || (scores.blastRadius >= 3 && (scores.ambiguity >= 2 || scores.investigationNeed >= 2))) {
|
|
1604
1653
|
return 'map-impact';
|
|
1605
1654
|
}
|
|
@@ -1699,7 +1748,7 @@ const { pathToFileURL } = require('url');
|
|
|
1699
1748
|
),
|
|
1700
1749
|
'map-impact': (
|
|
1701
1750
|
(scores.sharedRisk ? 7 : 0)
|
|
1702
|
-
+ (scores.impactSignal ? 5 :
|
|
1751
|
+
+ (scores.impactSignal ? 5 : -5)
|
|
1703
1752
|
+ (scores.ambiguity * 2)
|
|
1704
1753
|
+ scores.investigationNeed
|
|
1705
1754
|
),
|
|
@@ -2254,45 +2303,124 @@ const { pathToFileURL } = require('url');
|
|
|
2254
2303
|
};
|
|
2255
2304
|
}
|
|
2256
2305
|
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2306
|
+
// Owner-token lock protocol, protocol-compatible with runtime token-utils withFileLock
|
|
2307
|
+
// and the advisory permission hooks (mkdir-based `<file>.lock` + owner liveness).
|
|
2308
|
+
// route-audit.json is a read-modify-write merge: without the lock, 16 concurrent
|
|
2309
|
+
// router invocations were verified to keep only 13/16 entries (lost updates).
|
|
2310
|
+
function isPidAlive(pid) {
|
|
2311
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
2312
|
+
try {
|
|
2313
|
+
process.kill(pid, 0);
|
|
2314
|
+
return true;
|
|
2315
|
+
} catch (error) {
|
|
2316
|
+
return error?.code === 'EPERM';
|
|
2260
2317
|
}
|
|
2318
|
+
}
|
|
2261
2319
|
|
|
2262
|
-
|
|
2263
|
-
|
|
2320
|
+
function readLockOwner(lockPath) {
|
|
2321
|
+
const owner = readJson(path.join(lockPath, 'owner'), null);
|
|
2322
|
+
return owner && Number.isInteger(owner.pid) && typeof owner.token === 'string'
|
|
2323
|
+
? owner
|
|
2324
|
+
: null;
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
async function withStateLock(lockPath, fn) {
|
|
2328
|
+
// Advisory bookkeeping: liveness over strictness — if the lock cannot be acquired
|
|
2329
|
+
// within maxWaitMs the merge runs unlocked (pre-lock behaviour) rather than hanging
|
|
2330
|
+
// or dropping the entry.
|
|
2331
|
+
const staleMs = 10000;
|
|
2332
|
+
const maxWaitMs = 5000;
|
|
2333
|
+
const startedAt = Date.now();
|
|
2334
|
+
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
|
2335
|
+
let held = false;
|
|
2336
|
+
while (!held) {
|
|
2264
2337
|
try {
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2338
|
+
fs.mkdirSync(lockPath);
|
|
2339
|
+
try {
|
|
2340
|
+
fs.writeFileSync(path.join(lockPath, 'owner'), JSON.stringify({
|
|
2341
|
+
pid: process.pid,
|
|
2342
|
+
token: ownerToken,
|
|
2343
|
+
ts: Date.now(),
|
|
2344
|
+
}));
|
|
2345
|
+
} catch (error) {
|
|
2346
|
+
try {
|
|
2347
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
2348
|
+
} catch {}
|
|
2349
|
+
throw error;
|
|
2350
|
+
}
|
|
2351
|
+
held = true;
|
|
2352
|
+
} catch (error) {
|
|
2353
|
+
if (!error || error.code !== 'EEXIST') return fn();
|
|
2354
|
+
try {
|
|
2355
|
+
const stat = fs.statSync(lockPath);
|
|
2356
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
2357
|
+
const owner = readLockOwner(lockPath);
|
|
2358
|
+
if (!owner || !isPidAlive(owner.pid)) {
|
|
2359
|
+
try {
|
|
2360
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
2361
|
+
continue;
|
|
2362
|
+
} catch {}
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
} catch {}
|
|
2366
|
+
if (Date.now() - startedAt > maxWaitMs) break;
|
|
2367
|
+
await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
try {
|
|
2371
|
+
return await fn();
|
|
2372
|
+
} finally {
|
|
2373
|
+
if (held) {
|
|
2374
|
+
const owner = readLockOwner(lockPath);
|
|
2375
|
+
if (owner?.token === ownerToken) {
|
|
2376
|
+
try {
|
|
2377
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
2378
|
+
} catch {}
|
|
2379
|
+
}
|
|
2268
2380
|
}
|
|
2269
2381
|
}
|
|
2382
|
+
}
|
|
2270
2383
|
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2384
|
+
async function appendRouteAuditEntry(filePath, entry) {
|
|
2385
|
+
if (!entry || typeof entry !== 'object') {
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
await withStateLock(`${filePath}.lock`, () => {
|
|
2390
|
+
let parsed = { entries: [] };
|
|
2391
|
+
if (fs.existsSync(filePath)) {
|
|
2392
|
+
try {
|
|
2393
|
+
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
2394
|
+
} catch {
|
|
2395
|
+
parsed = { entries: [] };
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
|
|
2400
|
+
const dedupeKey = stableMachineDigest({
|
|
2401
|
+
requestKey: entry.requestKey,
|
|
2402
|
+
executionMode: entry.executionMode,
|
|
2403
|
+
nextActionType: entry.nextActionType,
|
|
2404
|
+
nextMilestone: entry.nextMilestone,
|
|
2405
|
+
repeatCount: entry.repeatCount,
|
|
2406
|
+
rescueMode: entry.rescueMode,
|
|
2407
|
+
});
|
|
2408
|
+
const filtered = entries.filter((item) => {
|
|
2409
|
+
const itemKey = stableMachineDigest({
|
|
2410
|
+
requestKey: item?.requestKey || null,
|
|
2411
|
+
executionMode: item?.executionMode || null,
|
|
2412
|
+
nextActionType: item?.nextActionType || null,
|
|
2413
|
+
nextMilestone: item?.nextMilestone || null,
|
|
2414
|
+
repeatCount: item?.repeatCount || null,
|
|
2415
|
+
rescueMode: item?.rescueMode || null,
|
|
2416
|
+
});
|
|
2417
|
+
return itemKey !== dedupeKey;
|
|
2288
2418
|
});
|
|
2289
|
-
return itemKey !== dedupeKey;
|
|
2290
|
-
});
|
|
2291
2419
|
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
})
|
|
2420
|
+
writeJsonFileAtomic(filePath, JSON.stringify({
|
|
2421
|
+
entries: [entry, ...filtered].slice(0, 40),
|
|
2422
|
+
}));
|
|
2423
|
+
});
|
|
2296
2424
|
}
|
|
2297
2425
|
|
|
2298
2426
|
function shouldIncludePreviousContext({ routingContext = {}, useIndexedContext = true } = {}) {
|
|
@@ -2445,6 +2573,39 @@ const { pathToFileURL } = require('url');
|
|
|
2445
2573
|
const debounceMs = 10 * 60 * 1000;
|
|
2446
2574
|
const previousTs = Number(previous?.ts || 0);
|
|
2447
2575
|
const informationalNoTargetPrompt = isInformationalPrompt({ promptText, commandText }) && !filePath;
|
|
2576
|
+
// A delivery-only prompt ("push this to git", "đẩy bộ này lên git") starts a new,
|
|
2577
|
+
// mutation-free action and typically matches no skill. It must not inherit the
|
|
2578
|
+
// previous request's completion contract — carrying find-cause/map-impact debt
|
|
2579
|
+
// forward here made the Stop gate demand an edit that a push can never produce.
|
|
2580
|
+
const noRouteScores = deriveExecutionScores({
|
|
2581
|
+
promptText,
|
|
2582
|
+
commandText,
|
|
2583
|
+
targetFile: filePath,
|
|
2584
|
+
intentMode,
|
|
2585
|
+
taskType: '',
|
|
2586
|
+
});
|
|
2587
|
+
const noRouteSignalRaw = [
|
|
2588
|
+
routeSignals.promptRawText || '',
|
|
2589
|
+
routeSignals.promptNormalizedText || '',
|
|
2590
|
+
routeSignals.commandRawText || '',
|
|
2591
|
+
routeSignals.commandNormalizedText || '',
|
|
2592
|
+
].join('\n');
|
|
2593
|
+
const deliveryWordSignal = /\bgit\s+push\b/.test(noRouteSignalRaw)
|
|
2594
|
+
|| /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(noRouteSignalRaw)
|
|
2595
|
+
|| /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(noRouteSignalRaw)
|
|
2596
|
+
|| /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(noRouteSignalRaw);
|
|
2597
|
+
const deliveryOnlyNoRoute = deliveryWordSignal
|
|
2598
|
+
&& noRouteScores.editCertainty === 0
|
|
2599
|
+
&& !noRouteScores.implementSignal
|
|
2600
|
+
&& !noRouteScores.reviewSignal
|
|
2601
|
+
&& !noRouteScores.debugSignal
|
|
2602
|
+
&& !noRouteScores.failureSignal
|
|
2603
|
+
&& !noRouteScores.impactSignal
|
|
2604
|
+
&& !noRouteScores.buildSignal
|
|
2605
|
+
&& !noRouteScores.directTransformSignal
|
|
2606
|
+
&& !noRouteScores.smallFixSignal
|
|
2607
|
+
&& !noRouteScores.sharedRisk
|
|
2608
|
+
&& !filePath;
|
|
2448
2609
|
|
|
2449
2610
|
if (active.length === 0 || informationalNoTargetPrompt) {
|
|
2450
2611
|
const lastExplicitUserPromptText = promptText.trim()
|
|
@@ -2457,7 +2618,7 @@ const { pathToFileURL } = require('url');
|
|
|
2457
2618
|
targetFile: filePath,
|
|
2458
2619
|
contextIntent: '',
|
|
2459
2620
|
taskType: '',
|
|
2460
|
-
...(informationalNoTargetPrompt ? { executionMode: 'informational' } : {}),
|
|
2621
|
+
...((informationalNoTargetPrompt || deliveryOnlyNoRoute) ? { executionMode: 'informational' } : {}),
|
|
2461
2622
|
};
|
|
2462
2623
|
const previousContext = buildPreviousContextSnapshot({
|
|
2463
2624
|
projectRoot,
|
|
@@ -2486,7 +2647,7 @@ const { pathToFileURL } = require('url');
|
|
|
2486
2647
|
// route: completion-gate.sh reads routeSummary/requestKey from this state to decide
|
|
2487
2648
|
// whether a Stop is premature. Downgrading to a route-less state here silently disarms
|
|
2488
2649
|
// the gate mid-task, so carry the previous route forward until a real route replaces it.
|
|
2489
|
-
|
|
2650
|
+
writeJsonFileAtomic(statePath, JSON.stringify({
|
|
2490
2651
|
...(sessionId ? { sessionId } : {}),
|
|
2491
2652
|
fingerprint,
|
|
2492
2653
|
ts: now,
|
|
@@ -2494,8 +2655,8 @@ const { pathToFileURL } = require('url');
|
|
|
2494
2655
|
activeSkills: [],
|
|
2495
2656
|
routingContext: compactRoutingContext(routingContext),
|
|
2496
2657
|
...(previousContext ? { previousContext: compactPreviousContext(previousContext) } : {}),
|
|
2497
|
-
...(previous?.routeSummary ? { routeSummary: previous.routeSummary } : {}),
|
|
2498
|
-
...(previous?.requestKey ? { requestKey: previous.requestKey } : {}),
|
|
2658
|
+
...(previous?.routeSummary && !deliveryOnlyNoRoute ? { routeSummary: previous.routeSummary } : {}),
|
|
2659
|
+
...(previous?.requestKey && !deliveryOnlyNoRoute ? { requestKey: previous.requestKey } : {}),
|
|
2499
2660
|
}));
|
|
2500
2661
|
process.exit(0);
|
|
2501
2662
|
}
|
|
@@ -2536,6 +2697,7 @@ const { pathToFileURL } = require('url');
|
|
|
2536
2697
|
intentMode,
|
|
2537
2698
|
executionScores,
|
|
2538
2699
|
executionCandidates,
|
|
2700
|
+
signalText: `${routeSignals.promptNormalizedText || ''}\n${routeSignals.commandNormalizedText || ''}`,
|
|
2539
2701
|
});
|
|
2540
2702
|
const lastExplicitUserPromptText = promptText.trim()
|
|
2541
2703
|
|| previous?.routingContext?.lastExplicitUserPromptText
|
|
@@ -2606,7 +2768,7 @@ const { pathToFileURL } = require('url');
|
|
|
2606
2768
|
};
|
|
2607
2769
|
provisionalState.fingerprint = buildRouteStateFingerprint(provisionalState);
|
|
2608
2770
|
ensureDir(statePath);
|
|
2609
|
-
|
|
2771
|
+
writeJsonFileAtomic(statePath, JSON.stringify(provisionalState));
|
|
2610
2772
|
|
|
2611
2773
|
let cacheUtils = null;
|
|
2612
2774
|
if (fs.existsSync(cacheUtilsPath)) {
|
|
@@ -2667,8 +2829,8 @@ const { pathToFileURL } = require('url');
|
|
|
2667
2829
|
}
|
|
2668
2830
|
|
|
2669
2831
|
ensureDir(statePath);
|
|
2670
|
-
|
|
2671
|
-
appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
2832
|
+
writeJsonFileAtomic(statePath, JSON.stringify(reusedState));
|
|
2833
|
+
await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
2672
2834
|
state: reusedState,
|
|
2673
2835
|
}));
|
|
2674
2836
|
|
|
@@ -2756,8 +2918,8 @@ const { pathToFileURL } = require('url');
|
|
|
2756
2918
|
...(helpers ? { helpers } : {}),
|
|
2757
2919
|
routeSummary: compactRouteSummary(routeSummary),
|
|
2758
2920
|
};
|
|
2759
|
-
|
|
2760
|
-
appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
2921
|
+
writeJsonFileAtomic(statePath, JSON.stringify(sharedState));
|
|
2922
|
+
await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
2761
2923
|
routingContext,
|
|
2762
2924
|
routeSummary,
|
|
2763
2925
|
state: sharedState,
|
|
@@ -2792,7 +2954,14 @@ const { pathToFileURL } = require('url');
|
|
|
2792
2954
|
NODE
|
|
2793
2955
|
|
|
2794
2956
|
if [ -f "$THRESHOLD_SCRIPT" ]; then
|
|
2795
|
-
|
|
2957
|
+
# The 'prompt' phase only bookkeeps prompt-bearing events — a tool payload has no
|
|
2958
|
+
# prompt text and that phase exits before doing anything. Every PreToolUse/
|
|
2959
|
+
# PostToolUse call used to pay a second full node spawn for that no-op, doubling the
|
|
2960
|
+
# per-mutation hook latency. Tool payloads always carry tool_name; skip them.
|
|
2961
|
+
case "$INPUT" in
|
|
2962
|
+
*'"tool_name"'*) ;;
|
|
2963
|
+
*) printf '%s' "$INPUT" | CLAUDE_PROJECT_DIR="$PROJECT_ROOT" node "$THRESHOLD_SCRIPT" prompt 2>/dev/null || true ;;
|
|
2964
|
+
esac
|
|
2796
2965
|
fi
|
|
2797
2966
|
|
|
2798
2967
|
exit 0
|
|
@@ -9,6 +9,7 @@ ROUTE_CACHE_FILE="$PROJECT_ROOT/.claude/ukit/route-cache.json"
|
|
|
9
9
|
PROGRESS_FILE="$PROJECT_ROOT/.claude/ukit/verification-progress.json"
|
|
10
10
|
|
|
11
11
|
INPUT="$INPUT" STATE_FILE="$STATE_FILE" ROUTE_CACHE_FILE="$ROUTE_CACHE_FILE" PROGRESS_FILE="$PROGRESS_FILE" node <<'NODE'
|
|
12
|
+
const crypto = require('crypto');
|
|
12
13
|
const fs = require('fs');
|
|
13
14
|
const path = require('path');
|
|
14
15
|
|
|
@@ -41,6 +42,23 @@ function sleepSync(ms) {
|
|
|
41
42
|
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
function isPidAlive(pid) {
|
|
46
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
47
|
+
try {
|
|
48
|
+
process.kill(pid, 0);
|
|
49
|
+
return true;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return error?.code === 'EPERM';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readLockOwner(lockPath) {
|
|
56
|
+
const owner = readJson(path.join(lockPath, 'owner'), null);
|
|
57
|
+
return owner && Number.isInteger(owner.pid) && typeof owner.token === 'string'
|
|
58
|
+
? owner
|
|
59
|
+
: null;
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
// mkdir-based lock, protocol-compatible with runtime token-utils withFileLock. Sync on
|
|
45
63
|
// purpose (see sleepSync). Fail-open after 5s: this hook must never hang a PreToolUse
|
|
46
64
|
// chain, and a lost progress entry only costs one repeated advisory.
|
|
@@ -48,21 +66,37 @@ function withLockSync(lockPath, fn) {
|
|
|
48
66
|
const staleMs = 10000;
|
|
49
67
|
const maxWaitMs = 5000;
|
|
50
68
|
const startedAt = Date.now();
|
|
69
|
+
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
|
51
70
|
let held = false;
|
|
52
71
|
while (!held) {
|
|
53
72
|
try {
|
|
54
73
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
55
74
|
fs.mkdirSync(lockPath);
|
|
75
|
+
try {
|
|
76
|
+
fs.writeFileSync(path.join(lockPath, 'owner'), JSON.stringify({
|
|
77
|
+
pid: process.pid,
|
|
78
|
+
token: ownerToken,
|
|
79
|
+
ts: Date.now(),
|
|
80
|
+
}));
|
|
81
|
+
} catch (error) {
|
|
82
|
+
try {
|
|
83
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
84
|
+
} catch {}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
56
87
|
held = true;
|
|
57
88
|
} catch (error) {
|
|
58
89
|
if (!error || error.code !== 'EEXIST') return fn();
|
|
59
90
|
try {
|
|
60
91
|
const stat = fs.statSync(lockPath);
|
|
61
92
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
93
|
+
const owner = readLockOwner(lockPath);
|
|
94
|
+
if (!owner || !isPidAlive(owner.pid)) {
|
|
95
|
+
try {
|
|
96
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
97
|
+
continue;
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
66
100
|
}
|
|
67
101
|
} catch {}
|
|
68
102
|
if (Date.now() - startedAt > maxWaitMs) break;
|
|
@@ -73,9 +107,12 @@ function withLockSync(lockPath, fn) {
|
|
|
73
107
|
return fn();
|
|
74
108
|
} finally {
|
|
75
109
|
if (held) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
110
|
+
const owner = readLockOwner(lockPath);
|
|
111
|
+
if (owner?.token === ownerToken) {
|
|
112
|
+
try {
|
|
113
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
79
116
|
}
|
|
80
117
|
}
|
|
81
118
|
}
|
|
@@ -288,14 +325,12 @@ function hasCompletedRecentTargetedLane({
|
|
|
288
325
|
});
|
|
289
326
|
}
|
|
290
327
|
|
|
291
|
-
function
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
fs.writeSync(1, `${advisory}\n`);
|
|
328
|
+
function advise(message) {
|
|
329
|
+
// This hook is intentionally advisory. The executable hard gates are the completion
|
|
330
|
+
// ledger, context hard-cap, protected-file, and destructive-command guards; turning
|
|
331
|
+
// a verification-order recommendation into an exit-2 refusal gave a stale opt-in env
|
|
332
|
+
// variable a way to freeze a run before it could diagnose or land its current change.
|
|
333
|
+
fs.writeSync(1, `${message.replace(/^BLOCKED:/, 'ADVISORY:')}\n`);
|
|
299
334
|
process.exit(0);
|
|
300
335
|
}
|
|
301
336
|
|
|
@@ -433,31 +468,31 @@ if (isTargetedVerification) {
|
|
|
433
468
|
}
|
|
434
469
|
|
|
435
470
|
if (policyMode === 'confirm-then-broad' && isBroadTestCommand(command) && !explicitBroadRequested && !recentTargetedLaneCompleted) {
|
|
436
|
-
|
|
471
|
+
advise(
|
|
437
472
|
`BLOCKED: Ask the user before broad verification. Routed policy is confirm-then-broad because the index could not localize related tests confidently.${helperCommand}`,
|
|
438
473
|
);
|
|
439
474
|
}
|
|
440
475
|
|
|
441
476
|
if ((policyMode === 'auto-run-targeted' || policyMode === 'auto-run-targeted-then-fallback') && !isPrimaryCommand && missingPrimary.length > 0) {
|
|
442
|
-
|
|
477
|
+
advise(
|
|
443
478
|
`BLOCKED: Run targeted verification first. Routed policy is ${policyMode} and the preferred first command(s) are: ${missingPrimaryText}.${helperCommand}`,
|
|
444
479
|
);
|
|
445
480
|
}
|
|
446
481
|
|
|
447
482
|
if (policyMode === 'auto-run-targeted' && !explicitBroadRequested && !isPrimaryCommand) {
|
|
448
|
-
|
|
483
|
+
advise(
|
|
449
484
|
`BLOCKED: Broader verification is not the default for this routed context. Stay with targeted verification unless the user explicitly requests a full suite. Preferred order: ${preferredText}.${helperCommand}`,
|
|
450
485
|
);
|
|
451
486
|
}
|
|
452
487
|
|
|
453
488
|
if (policyMode === 'auto-run-targeted-then-fallback' && !explicitBroadRequested && isVerificationCommand(command) && !isPrimaryCommand && !isFallbackCommand) {
|
|
454
|
-
|
|
489
|
+
advise(
|
|
455
490
|
`BLOCKED: Follow the routed verification order. This command is outside the recommended fallback lane. Preferred order: ${preferredText}.${helperCommand}`,
|
|
456
491
|
);
|
|
457
492
|
}
|
|
458
493
|
|
|
459
494
|
if (policyMode === 'docs-light' && !explicitBroadRequested && isBroadTestCommand(command)) {
|
|
460
|
-
|
|
495
|
+
advise(
|
|
461
496
|
`BLOCKED: Docs-focused change detected. Broad test runs are not the default for docs-light verification. Ask first if a full suite is still required.${helperCommand}`,
|
|
462
497
|
);
|
|
463
498
|
}
|
|
@@ -450,6 +450,7 @@ async function prepareTaskRoute({
|
|
|
450
450
|
intentMode,
|
|
451
451
|
executionScores,
|
|
452
452
|
executionCandidates,
|
|
453
|
+
signalText: buildNormalizedRouteSignalText(normalizedPrompt, normalizedCommand),
|
|
453
454
|
});
|
|
454
455
|
const preservedPrompt = normalizedPrompt || String(lastExplicitUserPromptText || '').trim();
|
|
455
456
|
const visionInfo = await resolveVisionLane({
|
|
@@ -1749,8 +1750,10 @@ function deriveExecutionMode({
|
|
|
1749
1750
|
intentMode = null,
|
|
1750
1751
|
executionScores = {},
|
|
1751
1752
|
executionCandidates = null,
|
|
1753
|
+
signalText = '',
|
|
1752
1754
|
} = {}) {
|
|
1753
1755
|
const raw = `${promptText ?? ''}\n${commandText ?? ''}`.toLowerCase();
|
|
1756
|
+
const signalRaw = `${raw}\n${String(signalText || '').toLowerCase()}`;
|
|
1754
1757
|
const scores = executionScores;
|
|
1755
1758
|
const candidates = executionCandidates ?? buildExecutionModeCandidates({
|
|
1756
1759
|
promptText,
|
|
@@ -1760,18 +1763,48 @@ function deriveExecutionMode({
|
|
|
1760
1763
|
executionScores,
|
|
1761
1764
|
});
|
|
1762
1765
|
const explicitReviewLead = /\b(review this|audit this|review\b.*\brelease readiness|verify\b.*\brelease readiness)\b/.test(raw);
|
|
1766
|
+
const releaseVerificationContinuation = /\b(?:pending\s+diff[- ]review|self-install|verify\s+(?:consistency|release)|release\s+(?:verification|readiness))\b/.test(raw)
|
|
1767
|
+
&& /\b(?:release|tag|publish|push|report)\b/.test(raw);
|
|
1763
1768
|
const strongImpactLead = scores.sharedRisk
|
|
1764
1769
|
&& (scores.impactSignal || /\b(check all affected|map all affected|across all affected)\b/.test(raw));
|
|
1765
1770
|
const boundedLocalBuildCandidate = scores.buildSignal && scores.boundedEditSignal && scores.explicitTarget && !scores.sharedRisk;
|
|
1771
|
+
// A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
|
|
1772
|
+
// repository mutation the ledger could ever receipt. With no edit/review/debug/
|
|
1773
|
+
// build signal present, routing it to an investigation lane fabricates write debt
|
|
1774
|
+
// and the completion gate then demands an edit that cannot exist. Delivery-only
|
|
1775
|
+
// requests carry no completion contract instead.
|
|
1776
|
+
const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
|
|
1777
|
+
|| /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
|
|
1778
|
+
|| /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
|
|
1779
|
+
|| /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
|
|
1780
|
+
const deliveryOnlySignal = deliveryWordSignal
|
|
1781
|
+
&& scores.editCertainty === 0
|
|
1782
|
+
&& !scores.implementSignal
|
|
1783
|
+
&& !scores.reviewSignal
|
|
1784
|
+
&& !scores.debugSignal
|
|
1785
|
+
&& !scores.failureSignal
|
|
1786
|
+
&& !scores.impactSignal
|
|
1787
|
+
&& !scores.buildSignal
|
|
1788
|
+
&& !scores.directTransformSignal
|
|
1789
|
+
&& !scores.smallFixSignal
|
|
1790
|
+
&& !scores.sharedRisk
|
|
1791
|
+
&& !targetFile;
|
|
1766
1792
|
|
|
1767
1793
|
if (isInformationalPrompt({ promptText, commandText, scores })) {
|
|
1768
1794
|
return 'informational';
|
|
1769
1795
|
}
|
|
1770
1796
|
|
|
1771
|
-
if (
|
|
1797
|
+
if (
|
|
1798
|
+
releaseVerificationContinuation
|
|
1799
|
+
|| ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal)
|
|
1800
|
+
) {
|
|
1772
1801
|
return 'review-release';
|
|
1773
1802
|
}
|
|
1774
1803
|
|
|
1804
|
+
if (deliveryOnlySignal) {
|
|
1805
|
+
return 'informational';
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1775
1808
|
if (strongImpactLead || (scores.blastRadius >= 3 && (scores.ambiguity >= 2 || scores.investigationNeed >= 2))) {
|
|
1776
1809
|
return 'map-impact';
|
|
1777
1810
|
}
|
|
@@ -1871,7 +1904,7 @@ function buildExecutionModeCandidates({
|
|
|
1871
1904
|
),
|
|
1872
1905
|
'map-impact': (
|
|
1873
1906
|
(scores.sharedRisk ? 7 : 0)
|
|
1874
|
-
+ (scores.impactSignal ? 5 :
|
|
1907
|
+
+ (scores.impactSignal ? 5 : -5)
|
|
1875
1908
|
+ (scores.ambiguity * 2)
|
|
1876
1909
|
+ scores.investigationNeed
|
|
1877
1910
|
),
|
|
@@ -2784,7 +2817,7 @@ function buildRouteRequestKey({
|
|
|
2784
2817
|
previousContextSnapshot = null,
|
|
2785
2818
|
recentOutputSnapshot = null,
|
|
2786
2819
|
} = {}) {
|
|
2787
|
-
return buildCompactMachineKey('route-
|
|
2820
|
+
return buildCompactMachineKey('route-v3', {
|
|
2788
2821
|
commandNamespace,
|
|
2789
2822
|
indexGeneratedAtMs,
|
|
2790
2823
|
projectFingerprint,
|