@ngockhoale/ukit 2.3.2 → 2.3.4
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 +100 -0
- package/package.json +1 -1
- package/src/cli/commands/diff.js +1 -1
- package/src/cli/commands/install.js +4 -1
- package/src/core/runtimeConfig.js +1 -1
- package/src/index/taskRouting.js +16 -0
- package/templates/.claude/hooks/auto-allow-bash.sh +16 -1
- package/templates/.claude/hooks/block-dangerous.sh +97 -16
- package/templates/.claude/hooks/context-hardcap-gate.sh +131 -36
- package/templates/.claude/hooks/context-window-guard.sh +33 -5
- package/templates/.claude/hooks/handoff-model-guard.sh +7 -0
- package/templates/.claude/hooks/handoff-resume.sh +71 -40
- package/templates/.claude/hooks/protect-files.sh +42 -18
- package/templates/.claude/hooks/sensitive-data-guard.sh +18 -2
- package/templates/.claude/hooks/skill-router.sh +81 -4
- package/templates/.claude/ukit/index/route-task.mjs +90 -23
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +149 -11
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +12 -1
- package/templates/.claude/ukit/runtime/reinject-context.mjs +4 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +83 -5
- package/templates/AGENTS.md +1 -1
- package/templates/CLAUDE.md +1 -1
- package/templates/docs/AI_HANDOFF/RULES.md +2 -2
- package/templates/ukit/storage/config.json +2 -2
|
@@ -166,7 +166,11 @@ async function main() {
|
|
|
166
166
|
const sharedStatePath = path.join(rootDir, '.claude', 'ukit', 'skill-router-state.json');
|
|
167
167
|
const routeCachePath = path.join(rootDir, '.claude', 'ukit', 'route-cache.json');
|
|
168
168
|
const routeAuditPath = path.join(rootDir, '.ukit', 'storage', 'cache', 'route-audit.json');
|
|
169
|
-
const
|
|
169
|
+
const persistedPreviousState = await readJson(sharedStatePath, {});
|
|
170
|
+
const sessionId = normalizeSessionId(readFlagValue(args, '--session-id'));
|
|
171
|
+
const previousStateOwnershipBlocked = hasRouteStateOwner(persistedPreviousState)
|
|
172
|
+
&& !isRouteStateCompatible(persistedPreviousState, sessionId);
|
|
173
|
+
const previousState = previousStateOwnershipBlocked ? {} : persistedPreviousState;
|
|
170
174
|
const escalationConfig = await readJson(path.join(rootDir, '.ukit', 'storage', 'config.json'), null);
|
|
171
175
|
const targetFile = readFlagValue(args, '--target');
|
|
172
176
|
const taskType = readFlagValue(args, '--type');
|
|
@@ -185,7 +189,7 @@ async function main() {
|
|
|
185
189
|
.trim();
|
|
186
190
|
|
|
187
191
|
if (!promptText && !commandText && !targetFile) {
|
|
188
|
-
console.error('Usage: node .codex/ukit/index/route-task.mjs "<prompt>" [--tool-command <cmd>] [--target <file>] [--type trivial|simple|non-trivial] [--adapter codex|claude|antigravity|opencode]');
|
|
192
|
+
console.error('Usage: node .codex/ukit/index/route-task.mjs "<prompt>" [--tool-command <cmd>] [--target <file>] [--type trivial|simple|non-trivial] [--session-id <id>] [--adapter codex|claude|antigravity|opencode]');
|
|
189
193
|
process.exitCode = 1;
|
|
190
194
|
return;
|
|
191
195
|
}
|
|
@@ -245,18 +249,33 @@ async function main() {
|
|
|
245
249
|
previousState,
|
|
246
250
|
requestKey,
|
|
247
251
|
});
|
|
252
|
+
const routeCacheEntry = previousStateOwnershipBlocked
|
|
253
|
+
? null
|
|
254
|
+
: await readRecentCacheEntry(routeCachePath, requestKey, {
|
|
255
|
+
maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
|
|
256
|
+
touch: false,
|
|
257
|
+
});
|
|
258
|
+
const cachedRouteState = reuseSharedRouteState({
|
|
259
|
+
previousState: routeCacheEntry,
|
|
260
|
+
requestKey,
|
|
261
|
+
});
|
|
262
|
+
const cachedRouteStateOwnershipBlocked = hasRouteStateOwner(routeCacheEntry)
|
|
263
|
+
&& !isRouteStateCompatible(routeCacheEntry, sessionId);
|
|
264
|
+
const canPersistRouteState = !previousStateOwnershipBlocked;
|
|
265
|
+
const canPersistCachedRouteState = !cachedRouteStateOwnershipBlocked;
|
|
266
|
+
|
|
248
267
|
if (reusableState) {
|
|
249
268
|
printRouteState(reusableState);
|
|
250
269
|
return;
|
|
251
270
|
}
|
|
252
271
|
|
|
253
|
-
|
|
254
|
-
|
|
272
|
+
if (cachedRouteState && canPersistCachedRouteState) {
|
|
273
|
+
await readRecentCacheEntry(routeCachePath, requestKey, {
|
|
255
274
|
maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
|
|
256
275
|
touch: true,
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
260
279
|
if (cachedRouteState) {
|
|
261
280
|
const rescuedRouteSummary = cachedRouteState.routeSummary
|
|
262
281
|
? {
|
|
@@ -277,16 +296,19 @@ async function main() {
|
|
|
277
296
|
}
|
|
278
297
|
const sharedState = {
|
|
279
298
|
...cachedRouteState,
|
|
299
|
+
...(sessionId ? { sessionId } : {}),
|
|
280
300
|
source: 'task-route',
|
|
281
301
|
ts: Date.now(),
|
|
282
302
|
requestKey,
|
|
283
303
|
routeSummary: rescuedRouteSummary,
|
|
284
304
|
};
|
|
285
305
|
sharedState.fingerprint = buildRouteStateFingerprint(sharedState);
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
306
|
+
if (canPersistCachedRouteState) {
|
|
307
|
+
await writeJson(sharedStatePath, sharedState);
|
|
308
|
+
await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
309
|
+
state: sharedState,
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
290
312
|
printRouteState(sharedState);
|
|
291
313
|
return;
|
|
292
314
|
}
|
|
@@ -324,15 +346,18 @@ async function main() {
|
|
|
324
346
|
source: 'task-route',
|
|
325
347
|
requestKey,
|
|
326
348
|
indexGeneratedAtMs,
|
|
349
|
+
sessionId,
|
|
327
350
|
});
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
351
|
+
if (canPersistRouteState) {
|
|
352
|
+
await writeJson(sharedStatePath, sharedState);
|
|
353
|
+
await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
354
|
+
route,
|
|
355
|
+
state: sharedState,
|
|
356
|
+
}));
|
|
357
|
+
await writeRecentCacheEntry(routeCachePath, compactRouteCacheState(sharedState), {
|
|
358
|
+
maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
336
361
|
|
|
337
362
|
printRouteState(sharedState);
|
|
338
363
|
}
|
|
@@ -1509,6 +1534,7 @@ function buildRouteSummary({
|
|
|
1509
1534
|
executionCandidates,
|
|
1510
1535
|
});
|
|
1511
1536
|
const executionContract = buildExecutionContract(executionMode);
|
|
1537
|
+
const continuousExecution = buildContinuousExecutionPolicy(autonomyLevel);
|
|
1512
1538
|
const tierLane = buildTierLane({ modelTier: executionContract?.modelTier ?? null });
|
|
1513
1539
|
const completionState = buildCompletionState({
|
|
1514
1540
|
executionMode,
|
|
@@ -1555,6 +1581,8 @@ function buildRouteSummary({
|
|
|
1555
1581
|
tierLane,
|
|
1556
1582
|
completionState,
|
|
1557
1583
|
continuationState,
|
|
1584
|
+
autonomyLevel,
|
|
1585
|
+
continuousExecution,
|
|
1558
1586
|
...(expectedSourceFiles.length > 0 ? { expectedSourceFiles } : {}),
|
|
1559
1587
|
intentMode: routingContext.intentMode ?? null,
|
|
1560
1588
|
handoffFile,
|
|
@@ -1573,6 +1601,19 @@ function buildRouteSummary({
|
|
|
1573
1601
|
};
|
|
1574
1602
|
}
|
|
1575
1603
|
|
|
1604
|
+
// Execution-ledger.mjs mirrors this policy for its Stop gate; the two must stay aligned.
|
|
1605
|
+
// MAX_CONTINUATIONS (6) below is the same constant the ledger enforces for bounded modes.
|
|
1606
|
+
function buildContinuousExecutionPolicy(autonomyLevel) {
|
|
1607
|
+
if (autonomyLevel === 'vibecode') {
|
|
1608
|
+
return {
|
|
1609
|
+
enabled: true,
|
|
1610
|
+
stopOn: ['completion-evidence', 'genuine-blocker', 'dangerous-command-decision'],
|
|
1611
|
+
maxContinuations: null,
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
return { enabled: false, maxContinuations: 6 };
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1576
1617
|
function deriveContextMode(taskType) {
|
|
1577
1618
|
if (taskType === 'trivial' || taskType === 'simple') return 'LITE';
|
|
1578
1619
|
if (taskType === 'non-trivial' || taskType === 'shared-simple') return 'FULL';
|
|
@@ -2414,10 +2455,18 @@ function inferTaskType({ promptText, commandText, selectedIds, targetFile = null
|
|
|
2414
2455
|
return inferred;
|
|
2415
2456
|
}
|
|
2416
2457
|
|
|
2458
|
+
// Must mirror src/index/taskRouting.js: the printed helper command is meant to be pasted
|
|
2459
|
+
// into a shell, so arguments need POSIX single-quote escaping — JSON.stringify's double
|
|
2460
|
+
// quotes leave $(...), backticks, and $VAR open to shell expansion.
|
|
2461
|
+
function shellEscape(str) {
|
|
2462
|
+
if (typeof str !== 'string') return '';
|
|
2463
|
+
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2417
2466
|
function buildHelperCommand({ commandNamespace = '.claude', scriptName, intent = '', targetFile = null, taskType = null }) {
|
|
2418
2467
|
const parts = ['node', `${commandNamespace}/ukit/index/${scriptName}`];
|
|
2419
|
-
if (intent) parts.push(
|
|
2420
|
-
if (targetFile) parts.push('--target',
|
|
2468
|
+
if (intent) parts.push(shellEscape(intent));
|
|
2469
|
+
if (targetFile) parts.push('--target', shellEscape(targetFile));
|
|
2421
2470
|
if (taskType) parts.push('--type', taskType);
|
|
2422
2471
|
return parts.join(' ');
|
|
2423
2472
|
}
|
|
@@ -2625,7 +2674,9 @@ function normalizeRelativeFile(rootDir, rawFilePath) {
|
|
|
2625
2674
|
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
2626
2675
|
return relative.replaceAll('\\', '/');
|
|
2627
2676
|
}
|
|
2628
|
-
|
|
2677
|
+
// Absolute targets outside the project root are not routable repo files (src twin
|
|
2678
|
+
// returns null here) — emitting them pulled outlines of foreign files into context.
|
|
2679
|
+
return null;
|
|
2629
2680
|
}
|
|
2630
2681
|
|
|
2631
2682
|
return trimmed.replace(/^\.\/+/, '').replaceAll('\\', '/');
|
|
@@ -2665,11 +2716,25 @@ function readFlagValue(argv, flag) {
|
|
|
2665
2716
|
return withEquals ? withEquals.slice(flag.length + 1) : null;
|
|
2666
2717
|
}
|
|
2667
2718
|
|
|
2719
|
+
function normalizeSessionId(value) {
|
|
2720
|
+
const normalized = value === undefined || value === null ? '' : String(value).trim();
|
|
2721
|
+
return normalized || null;
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
function hasRouteStateOwner(state) {
|
|
2725
|
+
return Boolean(normalizeSessionId(state?.sessionId));
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
function isRouteStateCompatible(state, sessionId) {
|
|
2729
|
+
const owner = normalizeSessionId(state?.sessionId);
|
|
2730
|
+
return Boolean(owner && sessionId && owner === sessionId);
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2668
2733
|
function isFlagOrValue(argv, index) {
|
|
2669
2734
|
const arg = argv[index];
|
|
2670
2735
|
if (!arg.startsWith('--')) {
|
|
2671
2736
|
const prev = argv[index - 1];
|
|
2672
|
-
return Boolean(prev && ['--root', '--target', '--type', '--tool-command', '--last-prompt', '--adapter'].includes(prev));
|
|
2737
|
+
return Boolean(prev && ['--root', '--target', '--type', '--tool-command', '--last-prompt', '--session-id', '--adapter'].includes(prev));
|
|
2673
2738
|
}
|
|
2674
2739
|
return true;
|
|
2675
2740
|
}
|
|
@@ -2805,6 +2870,7 @@ function createSharedRouteState({
|
|
|
2805
2870
|
route,
|
|
2806
2871
|
source = 'task-route',
|
|
2807
2872
|
requestKey = null,
|
|
2873
|
+
sessionId = null,
|
|
2808
2874
|
}) {
|
|
2809
2875
|
const helpers = compactHelpers({
|
|
2810
2876
|
verificationRecommendation: route.verificationRecommendation ?? null,
|
|
@@ -2812,6 +2878,7 @@ function createSharedRouteState({
|
|
|
2812
2878
|
});
|
|
2813
2879
|
|
|
2814
2880
|
return {
|
|
2881
|
+
...(sessionId ? { sessionId } : {}),
|
|
2815
2882
|
requestKey,
|
|
2816
2883
|
fingerprint: buildRouteStateFingerprint(route),
|
|
2817
2884
|
ts: Date.now(),
|
|
@@ -7,6 +7,8 @@ import path from 'node:path';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
|
|
9
9
|
const LEDGER_VERSION = 1;
|
|
10
|
+
const RESUME_INTENT_VERSION = 1;
|
|
11
|
+
const RESUME_INTENT_TTL_MS = 30 * 60 * 1000;
|
|
10
12
|
const MAX_RECEIPTS = 24;
|
|
11
13
|
const MAX_SOURCE_FILES = 16;
|
|
12
14
|
const MAX_CONTINUATIONS = 6;
|
|
@@ -30,11 +32,17 @@ function firstDefined(values) {
|
|
|
30
32
|
return values.find((value) => value !== undefined && value !== null);
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
function
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
function explicitSessionId(value = {}) {
|
|
36
|
+
const candidate = firstDefined([
|
|
37
|
+
value.session_id,
|
|
38
|
+
value.sessionId,
|
|
37
39
|
]);
|
|
40
|
+
const normalized = candidate === undefined || candidate === null ? '' : String(candidate).trim();
|
|
41
|
+
return normalized || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sessionIdentity(payload = {}) {
|
|
45
|
+
const sessionId = explicitSessionId(payload);
|
|
38
46
|
if (sessionId) return safeSegment(sessionId);
|
|
39
47
|
|
|
40
48
|
const transcriptPath = firstDefined([
|
|
@@ -58,6 +66,17 @@ function ledgerPath(projectRoot, payload = {}) {
|
|
|
58
66
|
);
|
|
59
67
|
}
|
|
60
68
|
|
|
69
|
+
function resumeIntentPath(projectRoot, sessionId) {
|
|
70
|
+
return path.join(
|
|
71
|
+
projectRoot,
|
|
72
|
+
'.ukit',
|
|
73
|
+
'storage',
|
|
74
|
+
'cache',
|
|
75
|
+
'resume-intents',
|
|
76
|
+
`${safeSegment(sessionId)}.json`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
61
80
|
async function readJson(filePath, fallback = null) {
|
|
62
81
|
try {
|
|
63
82
|
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
@@ -73,14 +92,112 @@ async function writeJsonAtomic(filePath, value) {
|
|
|
73
92
|
await fs.rename(tempPath, filePath);
|
|
74
93
|
}
|
|
75
94
|
|
|
76
|
-
export async function readRouteState(projectRoot) {
|
|
77
|
-
|
|
95
|
+
export async function readRouteState(projectRoot, payload = {}) {
|
|
96
|
+
const state = await readJson(path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json'), null);
|
|
97
|
+
const sessionId = explicitSessionId(payload);
|
|
98
|
+
const stateSessionId = explicitSessionId(state || {});
|
|
99
|
+
if (!state || !sessionId) return state;
|
|
100
|
+
if (!stateSessionId) return null;
|
|
101
|
+
return stateSessionId === sessionId ? state : null;
|
|
78
102
|
}
|
|
79
103
|
|
|
80
104
|
export async function readExecutionLedger(projectRoot, payload = {}) {
|
|
81
105
|
return readJson(ledgerPath(projectRoot, payload), null);
|
|
82
106
|
}
|
|
83
107
|
|
|
108
|
+
function promptKeyFromText(promptText) {
|
|
109
|
+
const normalized = String(promptText || '').trim();
|
|
110
|
+
if (!normalized) return null;
|
|
111
|
+
return `prompt-${crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 20)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function explicitPromptKey(routeState = {}) {
|
|
115
|
+
return promptKeyFromText(routeState?.routingContext?.lastExplicitUserPromptText);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hasUnfinishedCompletion(state = {}, ledger = {}) {
|
|
119
|
+
if (ledger?.blocker) return false;
|
|
120
|
+
const mode = state?.routeSummary?.executionMode || state?.routeSummary?.approachSelector?.executionMode;
|
|
121
|
+
if (!IMPLEMENT_MODES.has(mode)) return false;
|
|
122
|
+
const required = requiredEvidence(state);
|
|
123
|
+
if (required.length === 0) return false;
|
|
124
|
+
return required.some((item) => !evidenceSatisfied(item, ledger, state?.routeSummary || {}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resumeSessionHash(sessionId) {
|
|
128
|
+
return crypto.createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 32);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function readResumableExecution(projectRoot, payload = {}) {
|
|
132
|
+
const sessionId = explicitSessionId(payload);
|
|
133
|
+
if (!sessionId) return null;
|
|
134
|
+
const state = await readRouteState(projectRoot, payload);
|
|
135
|
+
const ledger = await readExecutionLedger(projectRoot, payload);
|
|
136
|
+
const promptKey = explicitPromptKey(state || {});
|
|
137
|
+
if (!state || !ledger || !ledger.sessionId || ledger.sessionId !== sessionId
|
|
138
|
+
|| ledger.sessionKey !== safeSegment(sessionId) || !ledger.requestKey
|
|
139
|
+
|| !ledger.promptKey || !promptKey || ledger.promptKey !== promptKey
|
|
140
|
+
|| !hasUnfinishedCompletion(state, ledger)) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return { state, ledger, sessionId };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function readResumeIntent(projectRoot, payload = {}, { consume = false, now = Date.now() } = {}) {
|
|
147
|
+
const sessionId = explicitSessionId(payload);
|
|
148
|
+
if (!sessionId) return null;
|
|
149
|
+
const intent = await readJson(resumeIntentPath(projectRoot, sessionId), null);
|
|
150
|
+
if (!intent || intent.version !== RESUME_INTENT_VERSION) return null;
|
|
151
|
+
if (intent.sessionKey !== safeSegment(sessionId) || intent.sessionHash !== resumeSessionHash(sessionId)) return null;
|
|
152
|
+
if (!intent.promptKey || !intent.requestKey || !Number.isFinite(intent.expiresAt) || intent.expiresAt <= now) return null;
|
|
153
|
+
|
|
154
|
+
const state = await readRouteState(projectRoot, payload);
|
|
155
|
+
const ledger = await readExecutionLedger(projectRoot, payload);
|
|
156
|
+
const promptKey = explicitPromptKey(state || {});
|
|
157
|
+
// Identity is session + promptKey, NOT requestKey. The router re-keys requestKey on
|
|
158
|
+
// every tool call (commandText/targetFile are part of the key), so requiring requestKey
|
|
159
|
+
// equality invalidated the intent after the FIRST tool call between the gate block and
|
|
160
|
+
// the compaction — the post-compact session then had no continuation instruction and
|
|
161
|
+
// sat idle silently. hasUnfinishedCompletion still re-validates against the CURRENT
|
|
162
|
+
// ledger evidence, so a request that finished meanwhile consumes nothing and never
|
|
163
|
+
// resumes; a genuinely different prompt produces a different promptKey and stays dead.
|
|
164
|
+
if (!state || !ledger || ledger.sessionId !== sessionId
|
|
165
|
+
|| ledger.sessionKey !== safeSegment(sessionId)
|
|
166
|
+
|| promptKey !== intent.promptKey
|
|
167
|
+
|| ledger.promptKey !== intent.promptKey
|
|
168
|
+
|| !hasUnfinishedCompletion(state, ledger)) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
if (consume) {
|
|
172
|
+
await fs.rm(resumeIntentPath(projectRoot, sessionId), { force: true });
|
|
173
|
+
}
|
|
174
|
+
return intent;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function writeResumeIntent(projectRoot, payload = {}, { now = Date.now() } = {}) {
|
|
178
|
+
const sessionId = explicitSessionId(payload);
|
|
179
|
+
if (!sessionId) return null;
|
|
180
|
+
const state = await readRouteState(projectRoot, payload);
|
|
181
|
+
const ledger = await readExecutionLedger(projectRoot, payload);
|
|
182
|
+
const promptKey = explicitPromptKey(state || {});
|
|
183
|
+
if (!state || !ledger || ledger.sessionId !== sessionId || !promptKey
|
|
184
|
+
|| ledger.promptKey !== promptKey || !ledger.requestKey || !hasUnfinishedCompletion(state, ledger)) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
const intent = {
|
|
188
|
+
version: RESUME_INTENT_VERSION,
|
|
189
|
+
sessionKey: safeSegment(sessionId),
|
|
190
|
+
sessionHash: resumeSessionHash(sessionId),
|
|
191
|
+
promptKey,
|
|
192
|
+
requestKey: ledger.requestKey,
|
|
193
|
+
routeFingerprint: state.fingerprint || null,
|
|
194
|
+
createdAt: now,
|
|
195
|
+
expiresAt: now + RESUME_INTENT_TTL_MS,
|
|
196
|
+
};
|
|
197
|
+
await writeJsonAtomic(resumeIntentPath(projectRoot, sessionId), intent);
|
|
198
|
+
return intent;
|
|
199
|
+
}
|
|
200
|
+
|
|
84
201
|
function explicitError(payload = {}) {
|
|
85
202
|
return [
|
|
86
203
|
payload.isError,
|
|
@@ -246,7 +363,7 @@ export async function recordExecutionReceipt({
|
|
|
246
363
|
harness = 'unknown',
|
|
247
364
|
} = {}) {
|
|
248
365
|
if (!projectRoot || !toolName) return null;
|
|
249
|
-
const routeState = await readRouteState(projectRoot);
|
|
366
|
+
const routeState = await readRouteState(projectRoot, payload);
|
|
250
367
|
const current = await readExecutionLedger(projectRoot, payload);
|
|
251
368
|
const ledger = !current || current.requestKey !== (routeState?.requestKey || null)
|
|
252
369
|
? carriedEvidenceLedger(freshLedger(payload, routeState, harness), current)
|
|
@@ -376,6 +493,9 @@ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}) {
|
|
|
376
493
|
|
|
377
494
|
export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
378
495
|
const routeSummary = state?.routeSummary || {};
|
|
496
|
+
// Explicit vibecode autonomy: the user asked UKit to run one prompt to a finished result,
|
|
497
|
+
// so the completion gate keeps pushing instead of releasing at the continuation cap.
|
|
498
|
+
const vibecode = routeSummary.autonomyLevel === 'vibecode';
|
|
379
499
|
const mode = routeSummary.executionMode || routeSummary.approachSelector?.executionMode || null;
|
|
380
500
|
const evidence = requiredEvidence(state);
|
|
381
501
|
if (ledger?.blocker) {
|
|
@@ -411,7 +531,7 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
|
411
531
|
const continuationCount = staleContinuations
|
|
412
532
|
? 0
|
|
413
533
|
: Number(effectiveLedger?.continuationCount || 0);
|
|
414
|
-
if (continuationCount >= MAX_CONTINUATIONS) {
|
|
534
|
+
if (!vibecode && continuationCount >= MAX_CONTINUATIONS) {
|
|
415
535
|
if (effectiveLedger?.notified === true) {
|
|
416
536
|
return {
|
|
417
537
|
continue: false,
|
|
@@ -431,7 +551,7 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
|
431
551
|
};
|
|
432
552
|
}
|
|
433
553
|
|
|
434
|
-
const finalAttempt = continuationCount === MAX_CONTINUATIONS - 1;
|
|
554
|
+
const finalAttempt = !vibecode && continuationCount === MAX_CONTINUATIONS - 1;
|
|
435
555
|
const instruction = recoveryInstruction(missingEvidence, effectiveLedger, routeSummary);
|
|
436
556
|
return {
|
|
437
557
|
continue: true,
|
|
@@ -440,6 +560,7 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
|
440
560
|
`UKit completion gate: missing ${missingEvidence.join(', ')}.`,
|
|
441
561
|
instruction,
|
|
442
562
|
finalAttempt ? 'Final automatic recovery attempt: finish now or report a concrete blocker with its evidence.' : null,
|
|
563
|
+
vibecode ? 'Continuous (vibecode) execution is active: keep working until the requested result is complete; UKit stops only on completion evidence, a genuine blocker, or a dangerous-command decision.' : null,
|
|
443
564
|
].filter(Boolean).join(' '),
|
|
444
565
|
};
|
|
445
566
|
}
|
|
@@ -490,15 +611,32 @@ async function main() {
|
|
|
490
611
|
return;
|
|
491
612
|
}
|
|
492
613
|
if (process.argv.includes('--evaluate-stop')) {
|
|
493
|
-
const state = await readRouteState(projectRoot);
|
|
614
|
+
const state = await readRouteState(projectRoot, payload);
|
|
494
615
|
const ledger = await readExecutionLedger(projectRoot, payload) || {};
|
|
495
616
|
const result = evaluateCompletion({ state, ledger });
|
|
496
617
|
|
|
618
|
+
// A session ledger with activity proves this session was routed and accumulating
|
|
619
|
+
// evidence. If the route state is now missing or unreadable (router crashed/timed out
|
|
620
|
+
// before persisting it, or the session stamp was lost), the gate cannot evaluate
|
|
621
|
+
// completion — and ending silently there is indistinguishable from a mid-run stall.
|
|
622
|
+
// Surface the loss so the user sees WHY the run released. Sessions that were never
|
|
623
|
+
// routed (no ledger activity at all) stay silent: there is nothing to report.
|
|
624
|
+
if (!state && (ledger.sourceSucceeded || ledger.writeAttempted
|
|
625
|
+
|| ledger.verificationAttempted || (ledger.receipts || []).length > 0)) {
|
|
626
|
+
process.stdout.write(`${JSON.stringify({
|
|
627
|
+
systemMessage: 'UKit completion gate: route state for this session was lost before the stop was evaluated (router did not persist it), so unfinished work could not be verified. Send the task again in a new message to re-route and finish it.',
|
|
628
|
+
})}\n`);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
497
632
|
// Claude Code invokes Stop again after a Stop hook blocks the first stop. Re-blocking
|
|
498
633
|
// that recovery turn creates a self-sustaining loop, so let it end normally instead.
|
|
499
634
|
// If work still lacks evidence, surface the recovery reason to the user rather than
|
|
500
635
|
// silently ending after the automatic continuation.
|
|
501
|
-
|
|
636
|
+
// Vibecode autonomy keeps pushing through the reentrant Stop as well: the recovery turn
|
|
637
|
+
// after a block is another chance to finish the work, not a release valve. Block again
|
|
638
|
+
// with the reason; the loop still ends the moment evidence lands or a blocker appears.
|
|
639
|
+
if (payload.stop_hook_active === true && state?.routeSummary?.autonomyLevel !== 'vibecode') {
|
|
502
640
|
if (result.continue || result.capped || result.notify) {
|
|
503
641
|
const recoveryReason = result.reason
|
|
504
642
|
|| 'UKit completion gate reached its continuation limit; unfinished work was not retried again.';
|
|
@@ -106,7 +106,18 @@ function run(payloadText, scriptPaths) {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
try {
|
|
109
|
-
const [, ,
|
|
109
|
+
const [, , payloadArg = '{}', ...scriptPaths] = process.argv;
|
|
110
|
+
// The bridge passes the payload as a temp file (`@path`) when it can: argv is capped
|
|
111
|
+
// (~256KB per arg on macOS) and PostToolUse Bash payloads embed whole tool outputs.
|
|
112
|
+
// A leading '@' cannot occur in raw JSON, so the two forms are unambiguous.
|
|
113
|
+
let payloadText = payloadArg;
|
|
114
|
+
if (payloadArg.startsWith('@')) {
|
|
115
|
+
try {
|
|
116
|
+
payloadText = fs.readFileSync(payloadArg.slice(1), 'utf8');
|
|
117
|
+
} catch {
|
|
118
|
+
payloadText = '{}';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
110
121
|
process.stdout.write(JSON.stringify(run(payloadText, scriptPaths)));
|
|
111
122
|
} catch (error) {
|
|
112
123
|
process.stdout.write(JSON.stringify({
|
|
@@ -58,6 +58,10 @@ async function main() {
|
|
|
58
58
|
...await buildRecentOutputLines(projectRoot, config),
|
|
59
59
|
...await buildResolverHintLines(projectRoot, state),
|
|
60
60
|
...await buildDuraOneHintLines(projectRoot),
|
|
61
|
+
// Post-compact discipline: the host summary keeps whatever it kept — the fastest way to
|
|
62
|
+
// re-inflate a just-compacted session is re-reading old files/logs to "recover" detail
|
|
63
|
+
// that is already on disk. Say it once, right where the fresh context starts.
|
|
64
|
+
'- Post-compact discipline: do NOT re-read old files/logs/tool outputs to rebuild context — durable state is on disk and in this block. Delegate broad reads/searches to subagents (separate windows), keep replies short, and let the main window rebuild headroom before any wide investigation.',
|
|
61
65
|
'- If Claude hooks block broad verification, follow the routed targeted order or ask for explicit full-suite confirmation.',
|
|
62
66
|
].filter(Boolean);
|
|
63
67
|
|
|
@@ -138,6 +138,26 @@ function buildHookPayload(hookEventName, fields = {}) {
|
|
|
138
138
|
return payload;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
// A chain script may emit a Claude-Code-shaped structured decision on stdout. `ask` means
|
|
142
|
+
// the human must decide — the chain layer passes it through as a non-block `ask` verdict and
|
|
143
|
+
// the host boundary in hook() converts it to what the host supports. `deny` is a block that
|
|
144
|
+
// carries the structured reason. Anything unparseable falls through to exit-code semantics.
|
|
145
|
+
function parseStructuredDecision(stdout) {
|
|
146
|
+
if (typeof stdout !== 'string' || !stdout.trim()) return null;
|
|
147
|
+
try {
|
|
148
|
+
const decision = JSON.parse(stdout)?.hookSpecificOutput;
|
|
149
|
+
if (decision?.hookEventName !== 'PreToolUse') return null;
|
|
150
|
+
const permissionDecision = decision.permissionDecision;
|
|
151
|
+
if (permissionDecision !== 'ask' && permissionDecision !== 'deny') return null;
|
|
152
|
+
return {
|
|
153
|
+
permissionDecision,
|
|
154
|
+
reason: typeof decision.permissionDecisionReason === 'string' ? decision.permissionDecisionReason : '',
|
|
155
|
+
};
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
141
161
|
function translateExecResult(scriptName, execResult) {
|
|
142
162
|
const killed = Boolean(execResult?.killed);
|
|
143
163
|
const code = killed ? 1 : (execResult?.code ?? 0);
|
|
@@ -150,6 +170,19 @@ function translateExecResult(scriptName, execResult) {
|
|
|
150
170
|
return { block: false, stdout, stderr };
|
|
151
171
|
}
|
|
152
172
|
if (code === 2) {
|
|
173
|
+
const structured = parseStructuredDecision(stdout);
|
|
174
|
+
if (structured?.permissionDecision === 'ask') {
|
|
175
|
+
return {
|
|
176
|
+
block: false,
|
|
177
|
+
ask: true,
|
|
178
|
+
reason: structured.reason || stderr || `${scriptName} requires a human decision`,
|
|
179
|
+
stdout,
|
|
180
|
+
stderr,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (structured?.permissionDecision === 'deny') {
|
|
184
|
+
return { block: true, reason: structured.reason || stderr || `${scriptName} denied the call`, stdout, stderr };
|
|
185
|
+
}
|
|
153
186
|
return { block: true, reason: stderr || `${scriptName} exited 2 (blocked)`, stdout, stderr };
|
|
154
187
|
}
|
|
155
188
|
if (classifyFailure(scriptName) === 'closed') {
|
|
@@ -215,6 +248,39 @@ function resolveNodeExecutable() {
|
|
|
215
248
|
return cachedNodeExecutable;
|
|
216
249
|
}
|
|
217
250
|
|
|
251
|
+
// argv is bounded: macOS allows ~256KB per argument (E2BIG), and PostToolUse Bash payloads
|
|
252
|
+
// embed the whole tool output — routinely past that limit — so the exec fails before ANY
|
|
253
|
+
// hook runs and the chain verdict is lost. Pass the payload through a temp file instead
|
|
254
|
+
// (runner reads `@path` markers); inline argv stays as the fallback for read-only project
|
|
255
|
+
// roots, and remains unambiguous because raw JSON never starts with '@'.
|
|
256
|
+
function sweepStalePayloadFiles(dir) {
|
|
257
|
+
try {
|
|
258
|
+
const cutoff = Date.now() - 60 * 60 * 1000;
|
|
259
|
+
for (const name of fs.readdirSync(dir)) {
|
|
260
|
+
const filePath = path.join(dir, name);
|
|
261
|
+
try {
|
|
262
|
+
if (fs.statSync(filePath).mtimeMs < cutoff) fs.rmSync(filePath, { force: true });
|
|
263
|
+
} catch { /* raced away — fine */ }
|
|
264
|
+
}
|
|
265
|
+
} catch { /* sweeping is best effort */ }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function writePayloadFile(projectRoot, payload) {
|
|
269
|
+
try {
|
|
270
|
+
const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-payloads');
|
|
271
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
272
|
+
sweepStalePayloadFiles(dir);
|
|
273
|
+
const filePath = path.join(
|
|
274
|
+
dir,
|
|
275
|
+
`${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}.json`,
|
|
276
|
+
);
|
|
277
|
+
fs.writeFileSync(filePath, JSON.stringify(payload), 'utf8');
|
|
278
|
+
return filePath;
|
|
279
|
+
} catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
218
284
|
export async function runScriptChain(
|
|
219
285
|
pi,
|
|
220
286
|
scripts,
|
|
@@ -231,15 +297,21 @@ export async function runScriptChain(
|
|
|
231
297
|
const scriptPaths = scripts.map((scriptName) => path.join(projectRoot, '.claude', 'hooks', scriptName));
|
|
232
298
|
const nodeExecutable = resolveNodeExecutable();
|
|
233
299
|
const startedAt = Date.now();
|
|
300
|
+
const payloadFile = writePayloadFile(projectRoot, payload);
|
|
301
|
+
const payloadArg = payloadFile ? `@${payloadFile}` : JSON.stringify(payload);
|
|
234
302
|
let execResult;
|
|
235
303
|
try {
|
|
236
304
|
execResult = await pi.exec(
|
|
237
305
|
nodeExecutable,
|
|
238
|
-
[runnerPath,
|
|
306
|
+
[runnerPath, payloadArg, ...scriptPaths],
|
|
239
307
|
{ cwd: projectRoot, timeout: chainExecTimeoutMs(scripts.length) },
|
|
240
308
|
);
|
|
241
309
|
} catch (error) {
|
|
242
310
|
execResult = { code: 1, stdout: '', stderr: error?.message ?? String(error), killed: false };
|
|
311
|
+
} finally {
|
|
312
|
+
if (payloadFile) {
|
|
313
|
+
try { fs.rmSync(payloadFile, { force: true }); } catch { /* best effort cleanup */ }
|
|
314
|
+
}
|
|
243
315
|
}
|
|
244
316
|
const elapsedMs = Date.now() - startedAt;
|
|
245
317
|
|
|
@@ -300,6 +372,9 @@ export async function runScriptChain(
|
|
|
300
372
|
if (verdict.block) {
|
|
301
373
|
return { block: true, reason: verdict.reason, context, invoked };
|
|
302
374
|
}
|
|
375
|
+
if (verdict.ask) {
|
|
376
|
+
return { block: false, ask: true, reason: verdict.reason, context, invoked };
|
|
377
|
+
}
|
|
303
378
|
}
|
|
304
379
|
|
|
305
380
|
return { block: false, context, invoked };
|
|
@@ -501,7 +576,7 @@ export async function runSessionStop(
|
|
|
501
576
|
) {
|
|
502
577
|
const metadata = runtimeMetadata(event, extensionContext);
|
|
503
578
|
const payload = buildHookPayload('Stop', metadata);
|
|
504
|
-
const state = suppliedState ?? await readRouteState(projectRoot);
|
|
579
|
+
const state = suppliedState ?? await readRouteState(projectRoot, payload);
|
|
505
580
|
const ledger = suppliedLedger ?? await readExecutionLedger(projectRoot, payload) ?? {};
|
|
506
581
|
const evaluation = evaluateCompletion({ state, ledger });
|
|
507
582
|
if (!evaluation.continue) {
|
|
@@ -545,9 +620,12 @@ export default function hook(pi) {
|
|
|
545
620
|
const projectRoot = process.env.CLAUDE_PROJECT_DIR || installedProjectRoot();
|
|
546
621
|
|
|
547
622
|
pi.on('tool_call', (event, context) => {
|
|
548
|
-
return runToolCall(pi, event, { projectRoot, context }).then((result) =>
|
|
549
|
-
|
|
550
|
-
|
|
623
|
+
return runToolCall(pi, event, { projectRoot, context }).then((result) => {
|
|
624
|
+
// omp's hook surface has no native `ask`; a human-decision verdict is surfaced by
|
|
625
|
+
// blocking with the decision reason, so a dangerous command never runs silently.
|
|
626
|
+
if (result.block || result.ask) return { block: true, reason: result.reason };
|
|
627
|
+
return undefined;
|
|
628
|
+
});
|
|
551
629
|
});
|
|
552
630
|
pi.on('tool_result', (event, context) => runToolResult(pi, event, { projectRoot, context }));
|
|
553
631
|
pi.on('before_agent_start', (event, context) => runBeforeAgentStart(pi, event, { projectRoot, context }));
|
package/templates/AGENTS.md
CHANGED
|
@@ -162,7 +162,7 @@ Khi Handoff mode: đọc `docs/AI_HANDOFF/RULES.md` để biết 4 phase (Idea+P
|
|
|
162
162
|
|
|
163
163
|
## Adaptive Autonomy
|
|
164
164
|
|
|
165
|
-
- `autonomy.level` in `.ukit/storage/config.json` controls how much UKit acts without asking first: `conservative` (ask more), `balanced` (default), `free-run` (auto-run more).
|
|
165
|
+
- `autonomy.level` in `.ukit/storage/config.json` controls how much UKit acts without asking first: `conservative` (ask more), `balanced` (default), `free-run` (auto-run more), `vibecode` (run one prompt to a finished result; the completion gate stops only on completion evidence, a genuine blocker, or a dangerous-command decision).
|
|
166
166
|
- End users should not need to change this; maintainers may tune it per-project.
|
|
167
167
|
|
|
168
168
|
## 3-Tier Model Routing
|
package/templates/CLAUDE.md
CHANGED
|
@@ -162,7 +162,7 @@ Khi Handoff mode: đọc `docs/AI_HANDOFF/RULES.md` để biết 4 phase (Idea+P
|
|
|
162
162
|
|
|
163
163
|
## Adaptive Autonomy
|
|
164
164
|
|
|
165
|
-
- `autonomy.level` in `.ukit/storage/config.json` controls how much UKit acts without asking first: `conservative` (ask more), `balanced` (default), `free-run` (auto-run more).
|
|
165
|
+
- `autonomy.level` in `.ukit/storage/config.json` controls how much UKit acts without asking first: `conservative` (ask more), `balanced` (default), `free-run` (auto-run more), `vibecode` (run one prompt to a finished result; the completion gate stops only on completion evidence, a genuine blocker, or a dangerous-command decision).
|
|
166
166
|
- End users should not need to change this; maintainers may tune it per-project.
|
|
167
167
|
|
|
168
168
|
## 3-Tier Model Routing
|