@ngockhoale/ukit 1.6.4 → 1.6.5

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.
@@ -2,6 +2,8 @@
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
5
7
  import * as indexCore from './lib/index-core.mjs';
6
8
  import {
7
9
  DEFAULT_RECENT_CACHE_MAX_ENTRIES,
@@ -9,6 +11,7 @@ import {
9
11
  writeRecentCacheEntry,
10
12
  } from './cache-utils.mjs';
11
13
  import { ROUTE_CATALOG } from './route-catalog.mjs';
14
+ import { detectUnicGateway } from './unic-gateway.mjs';
12
15
 
13
16
  const {
14
17
  resolveContext,
@@ -44,6 +47,119 @@ const buildNormalizedRouteSignalText = typeof indexCore.buildRouteSignalText ===
44
47
  .join('\n')
45
48
  .toLowerCase();
46
49
 
50
+ // --- Vision lane (TASK-007) ---------------------------------------------------
51
+ // Vision is a CAPABILITY lane, orthogonal to the lite/code/smart COST tier. It is emitted as
52
+ // separate `visionLane`/`visionModel`/`visionReason` fields alongside `modelTier`, never in place
53
+ // of it. `modelTier` must never take the value `vision` (see buildExecutionContract() below).
54
+ const __routeTaskDir = path.dirname(fileURLToPath(import.meta.url));
55
+ const EXTRACT_IMAGE_SCRIPT_PATH = path.join(__routeTaskDir, 'extract-image.mjs');
56
+ const VISION_DETECT_TIMEOUT_MS = 800;
57
+ const VISION_IMAGE_URL_RE = /https?:\/\/\S*\.(?:png|jpe?g|gif|webp|bmp)\b/i;
58
+ const VISION_IMAGE_PATH_RE = /\.(?:png|jpe?g|gif|webp|bmp)\b/i;
59
+
60
+ /**
61
+ * Cheap, synchronous, anchored detection against real file extensions / image URLs only.
62
+ * Deliberately does NOT match loose substrings like "imagine", "imagery", or "anh" — those
63
+ * contain image-ish text but never a literal `.png`/`.jpg`/etc extension.
64
+ */
65
+ function detectVisionReasonFromText(text = '') {
66
+ const value = String(text || '');
67
+ if (!value) return null;
68
+ if (VISION_IMAGE_URL_RE.test(value)) return 'url-in-text';
69
+ if (VISION_IMAGE_PATH_RE.test(value)) return 'path-in-text';
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Best-effort probe for a pasted image in the current session transcript, via extract-image.mjs
75
+ * --detect. Never throws, never blocks the hot path beyond a short timeout: any spawn failure,
76
+ * non-zero exit, timeout, or malformed JSON degrades to "no image detected".
77
+ */
78
+ function detectPastedImageViaExtractor({ rootDir = process.cwd() } = {}) {
79
+ try {
80
+ const result = spawnSync(process.execPath, [EXTRACT_IMAGE_SCRIPT_PATH, '--detect', '--json'], {
81
+ cwd: rootDir,
82
+ encoding: 'utf8',
83
+ timeout: VISION_DETECT_TIMEOUT_MS,
84
+ });
85
+ if (!result || result.error || result.status !== 0 || !result.stdout) {
86
+ return false;
87
+ }
88
+ const parsed = JSON.parse(result.stdout);
89
+ return Number(parsed?.imageCount) > 0;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Gateway-aware model pick (TASK-003 aware). Calls detectUnicGateway() directly (not the CLI) to
97
+ * avoid a spawn on the hot path. unicMode true -> unic-vision. unicMode false -> read
98
+ * orchestration.modelTiers.vision.fallbackModel from config (TASK-001). Neither available -> omit
99
+ * visionModel and return an advisory; a blind model is never silently named.
100
+ */
101
+ async function resolveVisionModel({ rootDir = process.cwd() } = {}) {
102
+ let gateway = null;
103
+ try {
104
+ gateway = detectUnicGateway({ rootDir });
105
+ } catch {
106
+ gateway = null;
107
+ }
108
+
109
+ if (gateway?.unicMode) {
110
+ return { visionModel: gateway.visionModel || 'unic-vision', visionAdvisory: null };
111
+ }
112
+
113
+ const config = await readJson(path.join(rootDir, '.ukit', 'storage', 'config.json'), null);
114
+ const fallbackModel = config?.orchestration?.modelTiers?.vision?.fallbackModel;
115
+ if (typeof fallbackModel === 'string' && fallbackModel.trim()) {
116
+ return { visionModel: fallbackModel.trim(), visionAdvisory: null };
117
+ }
118
+
119
+ return {
120
+ visionModel: null,
121
+ visionAdvisory: 'vision lane detected but no vision model is configured (UNIC gateway inactive '
122
+ + 'and orchestration.modelTiers.vision.fallbackModel is missing); no model was named to avoid '
123
+ + 'silently naming a blind model.',
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Detects the vision lane and, when applicable, resolves the model to use for it. Returns null
129
+ * when the task has no image signal at all (silent when not applicable — no visionLane line, and
130
+ * the (costlier) extractor probe only runs when the cheap flag/text checks found nothing).
131
+ */
132
+ async function resolveVisionLane({
133
+ rootDir = process.cwd(),
134
+ promptText = '',
135
+ commandText = '',
136
+ targetFile = '',
137
+ visionFlag = false,
138
+ } = {}) {
139
+ let visionReason = visionFlag ? 'explicit-flag' : null;
140
+
141
+ if (!visionReason) {
142
+ const haystack = [promptText, commandText, targetFile].filter(Boolean).join('\n');
143
+ visionReason = detectVisionReasonFromText(haystack);
144
+ }
145
+
146
+ if (!visionReason && detectPastedImageViaExtractor({ rootDir })) {
147
+ visionReason = 'pasted-image';
148
+ }
149
+
150
+ if (!visionReason) {
151
+ return null;
152
+ }
153
+
154
+ const { visionModel, visionAdvisory } = await resolveVisionModel({ rootDir });
155
+ return {
156
+ visionLane: true,
157
+ visionReason,
158
+ visionModel,
159
+ visionAdvisory,
160
+ };
161
+ }
162
+
47
163
  async function main() {
48
164
  const args = process.argv.slice(2);
49
165
  const rootDir = getRootDir(args);
@@ -51,9 +167,11 @@ async function main() {
51
167
  const routeCachePath = path.join(rootDir, '.claude', 'ukit', 'route-cache.json');
52
168
  const routeAuditPath = path.join(rootDir, '.ukit', 'storage', 'cache', 'route-audit.json');
53
169
  const previousState = await readJson(sharedStatePath, {});
170
+ const escalationConfig = await readJson(path.join(rootDir, '.ukit', 'storage', 'config.json'), null);
54
171
  const targetFile = readFlagValue(args, '--target');
55
172
  const taskType = readFlagValue(args, '--type');
56
173
  const commandText = readFlagValue(args, '--tool-command') ?? '';
174
+ const visionFlag = args.includes('--vision');
57
175
  const lastPrompt = readFlagValue(args, '--last-prompt')
58
176
  ?? previousState?.routingContext?.lastExplicitUserPromptText
59
177
  ?? '';
@@ -81,6 +199,7 @@ async function main() {
81
199
  adapter,
82
200
  lastExplicitUserPromptText: lastPrompt,
83
201
  commandNamespace,
202
+ visionFlag,
84
203
  });
85
204
  const useIndexedContext = shouldUseIndexedContext({
86
205
  activeSkills: preparedRoute.activeSkills,
@@ -145,11 +264,16 @@ async function main() {
145
264
  continuationState: advanceContinuationState(
146
265
  cachedRouteState.routeSummary.continuationState ?? null,
147
266
  cachedRouteState.routeSummary.continuationState ?? null,
267
+ resolveEscalationSettings(escalationConfig),
148
268
  ),
149
269
  }
150
270
  : null;
151
271
  if (rescuedRouteSummary) {
152
272
  applyRescueBiasToRouteSummary(rescuedRouteSummary);
273
+ applyEscalationToRouteSummary(rescuedRouteSummary, {
274
+ config: escalationConfig,
275
+ targetFile: cachedRouteState.routingContext?.targetFile ?? null,
276
+ });
153
277
  }
154
278
  const sharedState = {
155
279
  ...cachedRouteState,
@@ -185,6 +309,7 @@ async function main() {
185
309
  previousContextSnapshot,
186
310
  recentOutputSnapshot,
187
311
  previousRouteSummary: previousState?.routeSummary ?? null,
312
+ escalationConfig,
188
313
  });
189
314
  await seedHelperCaches({
190
315
  rootDir,
@@ -248,6 +373,7 @@ async function prepareTaskRoute({
248
373
  taskType = null,
249
374
  adapter = 'claude',
250
375
  lastExplicitUserPromptText = '',
376
+ visionFlag = false,
251
377
  } = {}) {
252
378
  const absoluteRoot = path.resolve(rootDir);
253
379
  const normalizedPrompt = String(promptText || '').trim();
@@ -301,6 +427,13 @@ async function prepareTaskRoute({
301
427
  executionCandidates,
302
428
  });
303
429
  const preservedPrompt = normalizedPrompt || String(lastExplicitUserPromptText || '').trim();
430
+ const visionInfo = await resolveVisionLane({
431
+ rootDir: absoluteRoot,
432
+ promptText: normalizedPrompt,
433
+ commandText: normalizedCommand,
434
+ targetFile: normalizedTarget,
435
+ visionFlag: Boolean(visionFlag),
436
+ });
304
437
 
305
438
  return {
306
439
  rootDir: absoluteRoot,
@@ -317,6 +450,10 @@ async function prepareTaskRoute({
317
450
  executionScores,
318
451
  executionCandidates,
319
452
  executionMode,
453
+ visionLane: Boolean(visionInfo?.visionLane),
454
+ visionReason: visionInfo?.visionReason ?? null,
455
+ visionModel: visionInfo?.visionModel ?? null,
456
+ visionAdvisory: visionInfo?.visionAdvisory ?? null,
320
457
  },
321
458
  };
322
459
  }
@@ -332,6 +469,7 @@ async function finalizeTaskRoute({
332
469
  previousContextSnapshot = null,
333
470
  recentOutputSnapshot = null,
334
471
  previousRouteSummary = null,
472
+ escalationConfig = null,
335
473
  } = {}) {
336
474
  const absoluteRoot = path.resolve(rootDir);
337
475
  const activeSkills = preparedRoute?.activeSkills ?? [];
@@ -399,8 +537,13 @@ async function finalizeTaskRoute({
399
537
  routeSummary.continuationState = advanceContinuationState(
400
538
  routeSummary.continuationState,
401
539
  previousRouteSummary?.continuationState ?? null,
540
+ resolveEscalationSettings(escalationConfig),
402
541
  );
403
542
  applyRescueBiasToRouteSummary(routeSummary);
543
+ applyEscalationToRouteSummary(routeSummary, {
544
+ config: escalationConfig,
545
+ targetFile: routingContext.targetFile ?? null,
546
+ });
404
547
  const approachSelector = routeSummary?.approachSelector ?? null;
405
548
 
406
549
  return {
@@ -436,6 +579,24 @@ function printRouteState(state) {
436
579
  if (state.routeSummary?.executionContract?.modelTier) {
437
580
  console.log(`modelTier: ${state.routeSummary.executionContract.modelTier}`);
438
581
  }
582
+ if (state.routeSummary?.escalatedTier) {
583
+ console.log(`escalatedTier: ${state.routeSummary.escalatedTier}`);
584
+ if (state.routeSummary.escalationReason) {
585
+ console.log(`escalationReason: ${state.routeSummary.escalationReason}`);
586
+ }
587
+ }
588
+ if (state.routeSummary?.visionLane) {
589
+ console.log(`visionLane: ${state.routeSummary.visionLane}`);
590
+ if (state.routeSummary.visionModel) {
591
+ console.log(`visionModel: ${state.routeSummary.visionModel}`);
592
+ }
593
+ if (state.routeSummary.visionReason) {
594
+ console.log(`visionReason: ${state.routeSummary.visionReason}`);
595
+ }
596
+ if (state.routeSummary.visionAdvisory) {
597
+ console.log(`visionAdvisory: ${state.routeSummary.visionAdvisory}`);
598
+ }
599
+ }
439
600
  if (nextDisplay) {
440
601
  console.log(`next: ${nextDisplay}`);
441
602
  }
@@ -1355,6 +1516,7 @@ function buildRouteSummary({
1355
1516
  );
1356
1517
  const nextActionCommand = compactHelperLane ? null : nextAction?.command ?? null;
1357
1518
  const handoffFile = routingContext.intentMode === 'handoff' ? 'docs/AI_HANDOFF/ACTIVE.md' : null;
1519
+ const visionLane = Boolean(routingContext.visionLane);
1358
1520
  const line = [
1359
1521
  routingContext.taskType ? `task=${routingContext.taskType}` : null,
1360
1522
  handoffFile ? `handoff=${handoffFile}` : null,
@@ -1386,6 +1548,12 @@ function buildRouteSummary({
1386
1548
  nextActionCommand,
1387
1549
  helperHint,
1388
1550
  contextMode,
1551
+ ...(visionLane ? {
1552
+ visionLane: true,
1553
+ visionReason: routingContext.visionReason ?? null,
1554
+ ...(routingContext.visionModel ? { visionModel: routingContext.visionModel } : {}),
1555
+ ...(routingContext.visionAdvisory ? { visionAdvisory: routingContext.visionAdvisory } : {}),
1556
+ } : {}),
1389
1557
  line: line || 'task=unknown',
1390
1558
  };
1391
1559
  }
@@ -1876,11 +2044,114 @@ function buildContinuationState({
1876
2044
  };
1877
2045
  }
1878
2046
 
1879
- function advanceContinuationState(continuationState = null, previousContinuationState = null) {
2047
+ // --- Escalation (TASK-009) ------------------------------------------------------------------
2048
+ // Consumes the repeatCount/rescueMode signal `advanceContinuationState` already computes and
2049
+ // exposes it to model-tier selection. Escalation is a COST ladder (lite -> code -> smart),
2050
+ // orthogonal to the vision capability lane (TASK-007): 'vision' must never appear in tierOrder
2051
+ // and escalatedTier must never take the value 'vision'. Thresholds are read from
2052
+ // orchestration.escalation (TASK-001); when config is absent or malformed, every threshold falls
2053
+ // back to the literal `2` / `3` that shipped before this change, so an unconfigured project sees
2054
+ // byte-identical behaviour.
2055
+ const DEFAULT_ESCALATION_DEBUG_LOOP_THRESHOLD = 2;
2056
+ const DEFAULT_ESCALATION_STUCK_RISK_THRESHOLD = 3;
2057
+ const DEFAULT_ESCALATION_TIER_ORDER = ['lite', 'code', 'smart'];
2058
+ const DEFAULT_ESCALATION_CAP = 'smart';
2059
+
2060
+ function resolveEscalationSettings(config = null) {
2061
+ const raw = config?.orchestration?.escalation;
2062
+ const enabled = typeof raw?.enabled === 'boolean' ? raw.enabled : true;
2063
+ const debugLoopThreshold = Number.isFinite(raw?.debugLoopThreshold) && raw.debugLoopThreshold > 0
2064
+ ? raw.debugLoopThreshold
2065
+ : DEFAULT_ESCALATION_DEBUG_LOOP_THRESHOLD;
2066
+ const stuckRiskThreshold = Number.isFinite(raw?.stuckRiskThreshold) && raw.stuckRiskThreshold > 0
2067
+ ? raw.stuckRiskThreshold
2068
+ : DEFAULT_ESCALATION_STUCK_RISK_THRESHOLD;
2069
+ const tierOrderCandidate = Array.isArray(raw?.tierOrder) && raw.tierOrder.length > 0
2070
+ && raw.tierOrder.every((tier) => typeof tier === 'string' && tier.trim())
2071
+ ? raw.tierOrder
2072
+ : DEFAULT_ESCALATION_TIER_ORDER;
2073
+ // 'vision' is a capability lane, never a cost rung — never let it into the escalation ladder.
2074
+ const tierOrder = tierOrderCandidate.includes('vision')
2075
+ ? DEFAULT_ESCALATION_TIER_ORDER
2076
+ : tierOrderCandidate;
2077
+ const cap = typeof raw?.cap === 'string' && tierOrder.includes(raw.cap)
2078
+ ? raw.cap
2079
+ : (tierOrder.includes(DEFAULT_ESCALATION_CAP) ? DEFAULT_ESCALATION_CAP : tierOrder[tierOrder.length - 1]);
2080
+
2081
+ return {
2082
+ enabled, debugLoopThreshold, stuckRiskThreshold, tierOrder, cap,
2083
+ };
2084
+ }
2085
+
2086
+ /**
2087
+ * Escalates exactly one rung above `modelTier` on `tierOrder`, capped at `cap`. Returns null when
2088
+ * escalation is disabled, `repeatCount` hasn't reached `debugLoopThreshold`, or `modelTier` isn't
2089
+ * on the cost ladder (e.g. no contract selected). Never returns 'vision'.
2090
+ */
2091
+ function computeEscalation({
2092
+ modelTier = null,
2093
+ repeatCount = null,
2094
+ config = null,
2095
+ targetFile = null,
2096
+ } = {}) {
2097
+ if (!modelTier || !Number.isFinite(repeatCount)) {
2098
+ return null;
2099
+ }
2100
+ const settings = resolveEscalationSettings(config);
2101
+ if (!settings.enabled || repeatCount < settings.debugLoopThreshold) {
2102
+ return null;
2103
+ }
2104
+ const currentIndex = settings.tierOrder.indexOf(modelTier);
2105
+ if (currentIndex === -1) {
2106
+ return null;
2107
+ }
2108
+ const capIndex = settings.tierOrder.indexOf(settings.cap);
2109
+ const effectiveCapIndex = capIndex === -1 ? settings.tierOrder.length - 1 : capIndex;
2110
+ const escalatedIndex = Math.min(currentIndex + 1, effectiveCapIndex);
2111
+ const escalatedTier = settings.tierOrder[escalatedIndex];
2112
+
2113
+ return {
2114
+ escalatedTier,
2115
+ escalationReason: `repeat-count=${repeatCount} threshold=${settings.debugLoopThreshold} file=${targetFile ?? 'null'}`,
2116
+ };
2117
+ }
2118
+
2119
+ /** Attaches escalatedTier/escalationReason to routeSummary, additive alongside modelTier. */
2120
+ function applyEscalationToRouteSummary(routeSummary = null, { config = null, targetFile = null } = {}) {
2121
+ if (!routeSummary || typeof routeSummary !== 'object') {
2122
+ return routeSummary;
2123
+ }
2124
+
2125
+ const escalation = computeEscalation({
2126
+ modelTier: routeSummary.executionContract?.modelTier ?? null,
2127
+ repeatCount: routeSummary.continuationState?.repeatCount ?? null,
2128
+ config,
2129
+ targetFile,
2130
+ });
2131
+
2132
+ if (escalation) {
2133
+ routeSummary.escalatedTier = escalation.escalatedTier;
2134
+ routeSummary.escalationReason = escalation.escalationReason;
2135
+ } else {
2136
+ delete routeSummary.escalatedTier;
2137
+ delete routeSummary.escalationReason;
2138
+ }
2139
+
2140
+ return routeSummary;
2141
+ }
2142
+
2143
+ function advanceContinuationState(continuationState = null, previousContinuationState = null, thresholds = null) {
1880
2144
  if (!continuationState || typeof continuationState !== 'object') {
1881
2145
  return continuationState;
1882
2146
  }
1883
2147
 
2148
+ const debugLoopThreshold = Number.isFinite(thresholds?.debugLoopThreshold)
2149
+ ? thresholds.debugLoopThreshold
2150
+ : DEFAULT_ESCALATION_DEBUG_LOOP_THRESHOLD;
2151
+ const stuckRiskThreshold = Number.isFinite(thresholds?.stuckRiskThreshold)
2152
+ ? thresholds.stuckRiskThreshold
2153
+ : DEFAULT_ESCALATION_STUCK_RISK_THRESHOLD;
2154
+
1884
2155
  if (!continuationState.required) {
1885
2156
  return {
1886
2157
  ...continuationState,
@@ -1903,7 +2174,7 @@ function advanceContinuationState(continuationState = null, previousContinuation
1903
2174
  const repeatCount = sameMilestone
1904
2175
  ? Math.max(1, Number(previousContinuationState?.repeatCount ?? 1) + 1)
1905
2176
  : 1;
1906
- const rescueMode = repeatCount >= 2
2177
+ const rescueMode = repeatCount >= debugLoopThreshold
1907
2178
  ? (
1908
2179
  currentReasons.includes('pending-bounded-context')
1909
2180
  ? 'finish-current-milestone-before-more-reading'
@@ -1914,14 +2185,14 @@ function advanceContinuationState(continuationState = null, previousContinuation
1914
2185
  )
1915
2186
  )
1916
2187
  : null;
1917
- const stuckRisk = repeatCount >= 3
2188
+ const stuckRisk = repeatCount >= stuckRiskThreshold
1918
2189
  ? 'high'
1919
- : (repeatCount === 2 ? 'elevated' : null);
1920
- const wideningBlocked = repeatCount >= 2 && Boolean(rescueMode);
1921
- const nextMilestone = repeatCount >= 2 && currentReasons.includes('missing-write-evidence')
2190
+ : (repeatCount === debugLoopThreshold ? 'elevated' : null);
2191
+ const wideningBlocked = repeatCount >= debugLoopThreshold && Boolean(rescueMode);
2192
+ const nextMilestone = repeatCount >= debugLoopThreshold && currentReasons.includes('missing-write-evidence')
1922
2193
  ? 'finish-write-before-more-analysis'
1923
2194
  : (
1924
- repeatCount >= 2 && currentReasons.includes('pending-bounded-context')
2195
+ repeatCount >= debugLoopThreshold && currentReasons.includes('pending-bounded-context')
1925
2196
  ? 'finish-current-milestone-before-more-reading'
1926
2197
  : continuationState.nextMilestone
1927
2198
  );
@@ -2376,6 +2647,8 @@ function buildRouteRequestKey({
2376
2647
  contextIntent: routingContext.contextIntent ?? '',
2377
2648
  taskType: routingContext.taskType ?? '',
2378
2649
  executionMode: routingContext.executionMode ?? '',
2650
+ visionLane: Boolean(routingContext.visionLane),
2651
+ visionReason: routingContext.visionReason ?? '',
2379
2652
  });
2380
2653
  }
2381
2654
 
@@ -2559,6 +2832,16 @@ function compactRouteSummary(routeSummary = null) {
2559
2832
  nextActionType: routeSummary.nextActionType ?? null,
2560
2833
  nextActionCommand: routeSummary.nextActionCommand ?? null,
2561
2834
  helperHint: routeSummary.helperHint ?? null,
2835
+ ...(routeSummary.escalatedTier ? {
2836
+ escalatedTier: routeSummary.escalatedTier,
2837
+ escalationReason: routeSummary.escalationReason ?? null,
2838
+ } : {}),
2839
+ ...(routeSummary.visionLane ? {
2840
+ visionLane: true,
2841
+ visionReason: routeSummary.visionReason ?? null,
2842
+ ...(routeSummary.visionModel ? { visionModel: routeSummary.visionModel } : {}),
2843
+ ...(routeSummary.visionAdvisory ? { visionAdvisory: routeSummary.visionAdvisory } : {}),
2844
+ } : {}),
2562
2845
  };
2563
2846
  }
2564
2847