@ngockhoale/ukit 2.3.8 → 2.3.10
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 +90 -0
- package/package.json +1 -1
- package/src/core/compact/threshold.js +13 -0
- package/src/core/fileOps.js +61 -7
- package/src/index/taskRouting.js +35 -2
- package/templates/.claude/agents/bug-debugger.md +2 -2
- package/templates/.claude/agents/feature-implementer.md +5 -3
- 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 +33 -8
- package/templates/.claude/hooks/context-window-guard.sh +1 -1
- package/templates/.claude/hooks/reset-compact-pressure.sh +47 -7
- package/templates/.claude/hooks/skill-router.sh +80 -7
- 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 +60 -5
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +361 -62
- package/templates/.claude/ukit/runtime/token-utils.mjs +69 -6
- package/templates/.omp/agents/bug-debugger.md +2 -2
- package/templates/.omp/agents/feature-implementer.md +5 -3
- package/templates/.omp/hooks/pre/ukit-bridge.js +68 -16
|
@@ -26,26 +26,63 @@ fi
|
|
|
26
26
|
|
|
27
27
|
# The lock directory lives beside the state file; its parent exists because the file does.
|
|
28
28
|
node -e '
|
|
29
|
+
const crypto = require("crypto");
|
|
29
30
|
const fs = require("fs");
|
|
31
|
+
const path = require("path");
|
|
32
|
+
|
|
33
|
+
function isPidAlive(pid) {
|
|
34
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0);
|
|
37
|
+
return true;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return error?.code === "EPERM";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readLockOwner(lockPath) {
|
|
44
|
+
try {
|
|
45
|
+
const owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner"), "utf8"));
|
|
46
|
+
return Number.isInteger(owner?.pid) && typeof owner.token === "string" ? owner : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
30
51
|
|
|
31
52
|
async function withLock(lockPath, fn) {
|
|
32
53
|
const staleMs = 10000;
|
|
33
54
|
const maxWaitMs = 5000;
|
|
34
55
|
const startedAt = Date.now();
|
|
56
|
+
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString("hex")}`;
|
|
35
57
|
let held = false;
|
|
36
58
|
while (!held) {
|
|
37
59
|
try {
|
|
38
60
|
fs.mkdirSync(lockPath);
|
|
61
|
+
try {
|
|
62
|
+
fs.writeFileSync(path.join(lockPath, "owner"), JSON.stringify({
|
|
63
|
+
pid: process.pid,
|
|
64
|
+
token: ownerToken,
|
|
65
|
+
ts: Date.now(),
|
|
66
|
+
}));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
try {
|
|
69
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
70
|
+
} catch {}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
39
73
|
held = true;
|
|
40
74
|
} catch (error) {
|
|
41
75
|
if (!error || error.code !== "EEXIST") return fn();
|
|
42
76
|
try {
|
|
43
77
|
const stat = fs.statSync(lockPath);
|
|
44
78
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
79
|
+
const owner = readLockOwner(lockPath);
|
|
80
|
+
if (!owner || !isPidAlive(owner.pid)) {
|
|
81
|
+
try {
|
|
82
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
83
|
+
continue;
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
49
86
|
}
|
|
50
87
|
} catch {}
|
|
51
88
|
if (Date.now() - startedAt > maxWaitMs) break;
|
|
@@ -56,9 +93,12 @@ async function withLock(lockPath, fn) {
|
|
|
56
93
|
return await fn();
|
|
57
94
|
} finally {
|
|
58
95
|
if (held) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
96
|
+
const owner = readLockOwner(lockPath);
|
|
97
|
+
if (owner?.token === ownerToken) {
|
|
98
|
+
try {
|
|
99
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
62
102
|
}
|
|
63
103
|
}
|
|
64
104
|
}
|
|
@@ -544,7 +544,7 @@ const { pathToFileURL } = require('url');
|
|
|
544
544
|
routingContext = {},
|
|
545
545
|
previousContextFingerprint = null,
|
|
546
546
|
}) {
|
|
547
|
-
return buildCompactMachineKey('route-
|
|
547
|
+
return buildCompactMachineKey('route-v3', {
|
|
548
548
|
commandNamespace,
|
|
549
549
|
indexGeneratedAtMs,
|
|
550
550
|
projectFingerprint,
|
|
@@ -1577,8 +1577,10 @@ const { pathToFileURL } = require('url');
|
|
|
1577
1577
|
intentMode = null,
|
|
1578
1578
|
executionScores = {},
|
|
1579
1579
|
executionCandidates = null,
|
|
1580
|
+
signalText = '',
|
|
1580
1581
|
} = {}) {
|
|
1581
1582
|
const raw = `${promptText || ''}\n${commandText || ''}`.toLowerCase();
|
|
1583
|
+
const signalRaw = `${raw}\n${String(signalText || '').toLowerCase()}`;
|
|
1582
1584
|
const scores = executionScores;
|
|
1583
1585
|
const candidates = executionCandidates ?? buildExecutionModeCandidates({
|
|
1584
1586
|
promptText,
|
|
@@ -1591,15 +1593,45 @@ const { pathToFileURL } = require('url');
|
|
|
1591
1593
|
const strongImpactLead = scores.sharedRisk
|
|
1592
1594
|
&& (scores.impactSignal || /\b(check all affected|map all affected|across all affected)\b/.test(raw));
|
|
1593
1595
|
const boundedLocalBuildCandidate = scores.buildSignal && scores.boundedEditSignal && scores.explicitTarget && !scores.sharedRisk;
|
|
1596
|
+
const releaseVerificationContinuation = /\b(?:pending\s+diff[- ]review|self-install|verify\s+(?:consistency|release)|release\s+(?:verification|readiness))\b/.test(raw)
|
|
1597
|
+
&& /\b(?:release|tag|publish|push|report)\b/.test(raw);
|
|
1598
|
+
// A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
|
|
1599
|
+
// repository mutation the ledger could ever receipt. With no edit/review/debug/
|
|
1600
|
+
// build signal present, routing it to an investigation lane fabricates write debt
|
|
1601
|
+
// and the completion gate then demands an edit that cannot exist. Delivery-only
|
|
1602
|
+
// requests carry no completion contract instead.
|
|
1603
|
+
const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
|
|
1604
|
+
|| /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
|
|
1605
|
+
|| /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
|
|
1606
|
+
|| /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
|
|
1607
|
+
const deliveryOnlySignal = deliveryWordSignal
|
|
1608
|
+
&& scores.editCertainty === 0
|
|
1609
|
+
&& !scores.implementSignal
|
|
1610
|
+
&& !scores.reviewSignal
|
|
1611
|
+
&& !scores.debugSignal
|
|
1612
|
+
&& !scores.failureSignal
|
|
1613
|
+
&& !scores.impactSignal
|
|
1614
|
+
&& !scores.buildSignal
|
|
1615
|
+
&& !scores.directTransformSignal
|
|
1616
|
+
&& !scores.smallFixSignal
|
|
1617
|
+
&& !scores.sharedRisk
|
|
1618
|
+
&& !targetFile;
|
|
1594
1619
|
|
|
1595
1620
|
if (isInformationalPrompt({ promptText, commandText, scores })) {
|
|
1596
1621
|
return 'informational';
|
|
1597
1622
|
}
|
|
1598
1623
|
|
|
1599
|
-
if (
|
|
1624
|
+
if (
|
|
1625
|
+
releaseVerificationContinuation
|
|
1626
|
+
|| ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal)
|
|
1627
|
+
) {
|
|
1600
1628
|
return 'review-release';
|
|
1601
1629
|
}
|
|
1602
1630
|
|
|
1631
|
+
if (deliveryOnlySignal) {
|
|
1632
|
+
return 'informational';
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1603
1635
|
if (strongImpactLead || (scores.blastRadius >= 3 && (scores.ambiguity >= 2 || scores.investigationNeed >= 2))) {
|
|
1604
1636
|
return 'map-impact';
|
|
1605
1637
|
}
|
|
@@ -1699,7 +1731,7 @@ const { pathToFileURL } = require('url');
|
|
|
1699
1731
|
),
|
|
1700
1732
|
'map-impact': (
|
|
1701
1733
|
(scores.sharedRisk ? 7 : 0)
|
|
1702
|
-
+ (scores.impactSignal ? 5 :
|
|
1734
|
+
+ (scores.impactSignal ? 5 : -5)
|
|
1703
1735
|
+ (scores.ambiguity * 2)
|
|
1704
1736
|
+ scores.investigationNeed
|
|
1705
1737
|
),
|
|
@@ -2445,6 +2477,39 @@ const { pathToFileURL } = require('url');
|
|
|
2445
2477
|
const debounceMs = 10 * 60 * 1000;
|
|
2446
2478
|
const previousTs = Number(previous?.ts || 0);
|
|
2447
2479
|
const informationalNoTargetPrompt = isInformationalPrompt({ promptText, commandText }) && !filePath;
|
|
2480
|
+
// A delivery-only prompt ("push this to git", "đẩy bộ này lên git") starts a new,
|
|
2481
|
+
// mutation-free action and typically matches no skill. It must not inherit the
|
|
2482
|
+
// previous request's completion contract — carrying find-cause/map-impact debt
|
|
2483
|
+
// forward here made the Stop gate demand an edit that a push can never produce.
|
|
2484
|
+
const noRouteScores = deriveExecutionScores({
|
|
2485
|
+
promptText,
|
|
2486
|
+
commandText,
|
|
2487
|
+
targetFile: filePath,
|
|
2488
|
+
intentMode,
|
|
2489
|
+
taskType: '',
|
|
2490
|
+
});
|
|
2491
|
+
const noRouteSignalRaw = [
|
|
2492
|
+
routeSignals.promptRawText || '',
|
|
2493
|
+
routeSignals.promptNormalizedText || '',
|
|
2494
|
+
routeSignals.commandRawText || '',
|
|
2495
|
+
routeSignals.commandNormalizedText || '',
|
|
2496
|
+
].join('\n');
|
|
2497
|
+
const deliveryWordSignal = /\bgit\s+push\b/.test(noRouteSignalRaw)
|
|
2498
|
+
|| /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(noRouteSignalRaw)
|
|
2499
|
+
|| /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(noRouteSignalRaw)
|
|
2500
|
+
|| /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(noRouteSignalRaw);
|
|
2501
|
+
const deliveryOnlyNoRoute = deliveryWordSignal
|
|
2502
|
+
&& noRouteScores.editCertainty === 0
|
|
2503
|
+
&& !noRouteScores.implementSignal
|
|
2504
|
+
&& !noRouteScores.reviewSignal
|
|
2505
|
+
&& !noRouteScores.debugSignal
|
|
2506
|
+
&& !noRouteScores.failureSignal
|
|
2507
|
+
&& !noRouteScores.impactSignal
|
|
2508
|
+
&& !noRouteScores.buildSignal
|
|
2509
|
+
&& !noRouteScores.directTransformSignal
|
|
2510
|
+
&& !noRouteScores.smallFixSignal
|
|
2511
|
+
&& !noRouteScores.sharedRisk
|
|
2512
|
+
&& !filePath;
|
|
2448
2513
|
|
|
2449
2514
|
if (active.length === 0 || informationalNoTargetPrompt) {
|
|
2450
2515
|
const lastExplicitUserPromptText = promptText.trim()
|
|
@@ -2457,7 +2522,7 @@ const { pathToFileURL } = require('url');
|
|
|
2457
2522
|
targetFile: filePath,
|
|
2458
2523
|
contextIntent: '',
|
|
2459
2524
|
taskType: '',
|
|
2460
|
-
...(informationalNoTargetPrompt ? { executionMode: 'informational' } : {}),
|
|
2525
|
+
...((informationalNoTargetPrompt || deliveryOnlyNoRoute) ? { executionMode: 'informational' } : {}),
|
|
2461
2526
|
};
|
|
2462
2527
|
const previousContext = buildPreviousContextSnapshot({
|
|
2463
2528
|
projectRoot,
|
|
@@ -2494,8 +2559,8 @@ const { pathToFileURL } = require('url');
|
|
|
2494
2559
|
activeSkills: [],
|
|
2495
2560
|
routingContext: compactRoutingContext(routingContext),
|
|
2496
2561
|
...(previousContext ? { previousContext: compactPreviousContext(previousContext) } : {}),
|
|
2497
|
-
...(previous?.routeSummary ? { routeSummary: previous.routeSummary } : {}),
|
|
2498
|
-
...(previous?.requestKey ? { requestKey: previous.requestKey } : {}),
|
|
2562
|
+
...(previous?.routeSummary && !deliveryOnlyNoRoute ? { routeSummary: previous.routeSummary } : {}),
|
|
2563
|
+
...(previous?.requestKey && !deliveryOnlyNoRoute ? { requestKey: previous.requestKey } : {}),
|
|
2499
2564
|
}));
|
|
2500
2565
|
process.exit(0);
|
|
2501
2566
|
}
|
|
@@ -2536,6 +2601,7 @@ const { pathToFileURL } = require('url');
|
|
|
2536
2601
|
intentMode,
|
|
2537
2602
|
executionScores,
|
|
2538
2603
|
executionCandidates,
|
|
2604
|
+
signalText: `${routeSignals.promptNormalizedText || ''}\n${routeSignals.commandNormalizedText || ''}`,
|
|
2539
2605
|
});
|
|
2540
2606
|
const lastExplicitUserPromptText = promptText.trim()
|
|
2541
2607
|
|| previous?.routingContext?.lastExplicitUserPromptText
|
|
@@ -2792,7 +2858,14 @@ const { pathToFileURL } = require('url');
|
|
|
2792
2858
|
NODE
|
|
2793
2859
|
|
|
2794
2860
|
if [ -f "$THRESHOLD_SCRIPT" ]; then
|
|
2795
|
-
|
|
2861
|
+
# The 'prompt' phase only bookkeeps prompt-bearing events — a tool payload has no
|
|
2862
|
+
# prompt text and that phase exits before doing anything. Every PreToolUse/
|
|
2863
|
+
# PostToolUse call used to pay a second full node spawn for that no-op, doubling the
|
|
2864
|
+
# per-mutation hook latency. Tool payloads always carry tool_name; skip them.
|
|
2865
|
+
case "$INPUT" in
|
|
2866
|
+
*'"tool_name"'*) ;;
|
|
2867
|
+
*) printf '%s' "$INPUT" | CLAUDE_PROJECT_DIR="$PROJECT_ROOT" node "$THRESHOLD_SCRIPT" prompt 2>/dev/null || true ;;
|
|
2868
|
+
esac
|
|
2796
2869
|
fi
|
|
2797
2870
|
|
|
2798
2871
|
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,
|
|
@@ -43,6 +43,15 @@ function finiteNumber(value, fallback = 0) {
|
|
|
43
43
|
return Number.isFinite(number) ? number : fallback;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// A malformed hard cap must fail back to a safe ceiling, never collapse to one token and
|
|
47
|
+
// brick every Edit/Write/Bash call. Unlike token estimates, configuration is an operator
|
|
48
|
+
// contract: accept only whole positive token counts.
|
|
49
|
+
function positiveInteger(value, fallback) {
|
|
50
|
+
return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0
|
|
51
|
+
? value
|
|
52
|
+
: fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
46
55
|
function uniqueStrings(values) {
|
|
47
56
|
const seen = new Set();
|
|
48
57
|
const unique = [];
|
|
@@ -452,7 +461,7 @@ export function buildCompactThresholds(config = {}) {
|
|
|
452
461
|
// env.CLAUDE_CODE_AUTO_COMPACT_WINDOW and omp's compaction.thresholdTokens are rendered
|
|
453
462
|
// from this number at install time (70% of it), so the client auto-compacts before the
|
|
454
463
|
// gate blocks tools without anyone hand-syncing a second value.
|
|
455
|
-
const hardCapTokens =
|
|
464
|
+
const hardCapTokens = positiveInteger(config?.compact?.hardCapTokens, 500_000);
|
|
456
465
|
|
|
457
466
|
return {
|
|
458
467
|
softThreshold,
|
|
@@ -490,6 +499,21 @@ export function resetCompactPressureState(rawState = {}, config = {}) {
|
|
|
490
499
|
}
|
|
491
500
|
|
|
492
501
|
const BOUNDARY_TAIL_BYTES = 4 * 1024 * 1024;
|
|
502
|
+
// Hook processes are short-lived, so an in-memory cache would not stop every Edit/Write
|
|
503
|
+
// from reopening and parsing a 4MB transcript tail. Persist a very short probe window in
|
|
504
|
+
// the session pressure record instead: a burst of mutations incurs one scan, not one per
|
|
505
|
+
// call. PreCompact is still immediate; this is only the fallback for host auto-compactions
|
|
506
|
+
// that skip PreCompact, where a two-second detection delay is preferable to killing the
|
|
507
|
+
// 4s hook budget on a slow volume.
|
|
508
|
+
const TRANSCRIPT_BOUNDARY_PROBE_TTL_MS = 2_000;
|
|
509
|
+
|
|
510
|
+
function normalizeTranscriptProbe(value) {
|
|
511
|
+
if (!value || typeof value !== 'object' || typeof value.path !== 'string' || !value.path) return null;
|
|
512
|
+
const checkedAt = finiteNumber(value.checkedAt, 0);
|
|
513
|
+
return checkedAt > 0
|
|
514
|
+
? { path: value.path, checkedAt, boundaryAt: finiteNumber(value.boundaryAt, 0) }
|
|
515
|
+
: null;
|
|
516
|
+
}
|
|
493
517
|
|
|
494
518
|
// Find the newest compaction recorded in the session transcript, as epoch ms (0 if none).
|
|
495
519
|
// Every real compaction — manual /compact AND harness auto-compact — appends a
|
|
@@ -542,18 +566,46 @@ export async function detectLatestCompactBoundaryAt(transcriptPath, { tailBytes
|
|
|
542
566
|
// history that was already summarised away and the hard-cap gate bricks a healthy
|
|
543
567
|
// session. Callers must persist when reset=true, or the reset would re-fire (and
|
|
544
568
|
// zero the counter) on every call.
|
|
545
|
-
export async function syncCompactPressureStateWithTranscript(
|
|
546
|
-
|
|
569
|
+
export async function syncCompactPressureStateWithTranscript(
|
|
570
|
+
rawState = null,
|
|
571
|
+
config = {},
|
|
572
|
+
transcriptPath = '',
|
|
573
|
+
{ now = Date.now(), probeTtlMs = TRANSCRIPT_BOUNDARY_PROBE_TTL_MS } = {},
|
|
574
|
+
) {
|
|
575
|
+
const normalizedPath = typeof transcriptPath === 'string' ? transcriptPath.trim() : '';
|
|
576
|
+
const previousProbe = normalizeTranscriptProbe(rawState?.transcriptBoundaryProbe);
|
|
577
|
+
// A process-persistent, session-scoped cache avoids rereading/parsing up to 4MB for every
|
|
578
|
+
// mutation during a tool burst. It keys on the path (not just time) so a resumed/new
|
|
579
|
+
// transcript always scans immediately. The short TTL is deliberately only a performance
|
|
580
|
+
// coalescing window; PreCompact remains the immediate source of truth.
|
|
581
|
+
const probeFresh = previousProbe
|
|
582
|
+
&& previousProbe.path === normalizedPath
|
|
583
|
+
&& now - previousProbe.checkedAt >= 0
|
|
584
|
+
&& now - previousProbe.checkedAt < probeTtlMs;
|
|
585
|
+
const boundaryAt = probeFresh
|
|
586
|
+
? previousProbe.boundaryAt
|
|
587
|
+
: await detectLatestCompactBoundaryAt(normalizedPath);
|
|
588
|
+
const probe = probeFresh
|
|
589
|
+
? previousProbe
|
|
590
|
+
: { path: normalizedPath, checkedAt: now, boundaryAt };
|
|
547
591
|
const accounted = finiteNumber(rawState?.lastCompactBoundaryAt, 0);
|
|
548
592
|
if (!boundaryAt || boundaryAt <= accounted) {
|
|
549
|
-
return {
|
|
593
|
+
return {
|
|
594
|
+
state: rawState && typeof rawState === 'object'
|
|
595
|
+
? { ...rawState, transcriptBoundaryProbe: probe }
|
|
596
|
+
: rawState,
|
|
597
|
+
reset: false,
|
|
598
|
+
probed: !probeFresh,
|
|
599
|
+
};
|
|
550
600
|
}
|
|
551
601
|
return {
|
|
552
602
|
state: {
|
|
553
603
|
...resetCompactPressureState(rawState ?? {}, config),
|
|
554
604
|
lastCompactBoundaryAt: boundaryAt,
|
|
605
|
+
transcriptBoundaryProbe: probe,
|
|
555
606
|
},
|
|
556
607
|
reset: true,
|
|
608
|
+
probed: !probeFresh,
|
|
557
609
|
};
|
|
558
610
|
}
|
|
559
611
|
|
|
@@ -786,6 +838,7 @@ export function buildCompactPressureState(rawState = null, config = {}) {
|
|
|
786
838
|
taskMode,
|
|
787
839
|
cooldownUntil: finiteNumber(rawState?.cooldownUntil, 0),
|
|
788
840
|
lastCompactBoundaryAt: finiteNumber(rawState?.lastCompactBoundaryAt, 0),
|
|
841
|
+
transcriptBoundaryProbe: normalizeTranscriptProbe(rawState?.transcriptBoundaryProbe),
|
|
789
842
|
routingContext: rawState?.routingContext && typeof rawState.routingContext === 'object'
|
|
790
843
|
? rawState.routingContext
|
|
791
844
|
: {},
|
|
@@ -1251,7 +1304,9 @@ async function runCli() {
|
|
|
1251
1304
|
sessionConfig,
|
|
1252
1305
|
payload.transcript_path,
|
|
1253
1306
|
);
|
|
1254
|
-
if (synced.reset) {
|
|
1307
|
+
if (synced.reset || synced.probed) {
|
|
1308
|
+
// Persist a clean probe too: this CLI runs in a fresh process on each hook,
|
|
1309
|
+
// so retaining only resets would make every prompt rescan the same tail.
|
|
1255
1310
|
await writeCompactPressureState(projectRoot, synced.state, sessionConfig);
|
|
1256
1311
|
}
|
|
1257
1312
|
} catch { /* advisory healing only — never block the prompt over it */ }
|