@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.
@@ -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 previousState = await readJson(sharedStatePath, {});
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
- const cachedRouteState = reuseSharedRouteState({
254
- previousState: await readRecentCacheEntry(routeCachePath, requestKey, {
272
+ if (cachedRouteState && canPersistCachedRouteState) {
273
+ await readRecentCacheEntry(routeCachePath, requestKey, {
255
274
  maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
256
275
  touch: true,
257
- }),
258
- requestKey,
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
- await writeJson(sharedStatePath, sharedState);
287
- await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
288
- state: sharedState,
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
- await writeJson(sharedStatePath, sharedState);
329
- await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
330
- route,
331
- state: sharedState,
332
- }));
333
- await writeRecentCacheEntry(routeCachePath, compactRouteCacheState(sharedState), {
334
- maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
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(JSON.stringify(intent));
2420
- if (targetFile) parts.push('--target', JSON.stringify(targetFile));
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
- return trimmed.replaceAll('\\', '/');
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 sessionIdentity(payload = {}) {
34
- const sessionId = firstDefined([
35
- payload.session_id,
36
- payload.sessionId,
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
- return readJson(path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json'), null);
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
- if (payload.stop_hook_active === true) {
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 [, , payloadText = '{}', ...scriptPaths] = process.argv;
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, JSON.stringify(payload), ...scriptPaths],
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
- result.block ? { block: true, reason: result.reason } : undefined
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 }));
@@ -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
@@ -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