@ngockhoale/ukit 1.6.4 → 1.6.6

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.
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * unic-gateway.mjs (TASK-003)
4
+ *
5
+ * Detects whether any locally configured AI tool (Claude Code, Codex, or Kilo Code) is pointed at
6
+ * the UNIC gateway — i.e. its base URL contains "unicjsc.com". Per the user's rule, a hit in ANY
7
+ * tool activates UNIC routing (`unicMode: true`), so the vision lane can pin `unic-vision` when it
8
+ * is available in the Codex model catalog, and fall back safely when it is not.
9
+ *
10
+ * Self-contained: reads only env vars and local files. Does NOT read `.ukit/storage/config.json`,
11
+ * so it has no dependency on TASK-001 and stays usable in Wave 1.
12
+ *
13
+ * Probe order (first hit sets `source`; every hit is recorded in `sources`):
14
+ * 1. process.env.ANTHROPIC_BASE_URL -> 'env'
15
+ * 2. process.env.OPENAI_BASE_URL -> 'env'
16
+ * 3. project .claude/settings.json -> env -> 'claude-settings'
17
+ * 4. ~/.claude/settings.json -> env -> 'claude-settings'
18
+ * 5. project .codex/config.toml -> 'codex'
19
+ * 6. ~/.codex/config.toml -> 'codex'
20
+ * 7. ~/.kilocode/secrets.json (presence only) -> 'kilo'
21
+ *
22
+ * SECURITY (non-negotiable): `~/.kilocode/secrets.json` is a real secrets file. This module only
23
+ * ever tests it for substring presence of "unicjsc.com", then lets the buffer fall out of scope.
24
+ * It never logs, returns, echoes, or otherwise embeds that file's contents anywhere — including in
25
+ * error messages. Nothing derived from that file may appear in this module's output beyond the
26
+ * literal source label "kilo".
27
+ *
28
+ * TOML (deliberate trade-off): no TOML parser dependency is added — repo `dependencies` is
29
+ * `{"yaml":"^2.8.2"}` only, and adding a parser for one `base_url` line fails Simplicity First.
30
+ * Codex config.toml is scanned with a narrow anchored regex that looks for a `base_url = "..."`
31
+ * assignment containing "unicjsc.com" on a single line. It does not attempt real TOML parsing, so
32
+ * exotic quoting/escaping (multi-line strings, unusual escapes) will not match. That degrades to
33
+ * "no hit" (`unicMode: false`), which fails safe rather than throwing or false-negatively passing.
34
+ *
35
+ * Contract: this module must NEVER throw and must NEVER exit non-zero, in any case (missing files,
36
+ * malformed JSON/TOML, unreadable paths, etc. all degrade to a clean "no hit").
37
+ *
38
+ * Test overrides: pass { rootDir, homeDir } directly to detectUnicGateway(), or set the env vars
39
+ * UKIT_TEST_ROOT / UKIT_TEST_HOME (used only when explicit options are not supplied) so tests never
40
+ * depend on the developer's real dotfiles.
41
+ */
42
+
43
+ import fs from 'node:fs';
44
+ import os from 'node:os';
45
+ import path from 'node:path';
46
+ import { fileURLToPath } from 'node:url';
47
+
48
+ const UNIC_TOKEN = 'unicjsc.com';
49
+ const VISION_MODEL_ALIAS = 'unic-vision';
50
+
51
+ // Narrow, anchored: a `base_url` key assignment whose quoted value contains the UNIC token.
52
+ // See file header "TOML" note for why this is a regex and not a real parser.
53
+ const CODEX_BASE_URL_UNIC_RE = /\bbase_url\s*=\s*(["'])(?:(?!\1)[^\r\n])*unicjsc\.com(?:(?!\1)[^\r\n])*\1/i;
54
+
55
+ function safeReadFile(filePath) {
56
+ try {
57
+ return fs.readFileSync(filePath, 'utf8');
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function safeReadJson(filePath) {
64
+ const raw = safeReadFile(filePath);
65
+ if (raw == null) return null;
66
+ try {
67
+ return JSON.parse(raw);
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function resolveHomeDir(overrideHome) {
74
+ if (overrideHome) return overrideHome;
75
+ if (process.env.UKIT_TEST_HOME) return process.env.UKIT_TEST_HOME;
76
+ return os.homedir();
77
+ }
78
+
79
+ function resolveRootDir(overrideRoot) {
80
+ if (overrideRoot) return overrideRoot;
81
+ if (process.env.UKIT_TEST_ROOT) return process.env.UKIT_TEST_ROOT;
82
+ return process.cwd();
83
+ }
84
+
85
+ function probeEnvVar(name) {
86
+ const value = process.env[name];
87
+ return typeof value === 'string' && value.includes(UNIC_TOKEN);
88
+ }
89
+
90
+ function probeClaudeSettings(filePath) {
91
+ const json = safeReadJson(filePath);
92
+ const envBlock = json && typeof json === 'object' ? json.env : null;
93
+ if (!envBlock || typeof envBlock !== 'object') return false;
94
+ return Object.values(envBlock).some(
95
+ (value) => typeof value === 'string' && value.includes(UNIC_TOKEN),
96
+ );
97
+ }
98
+
99
+ function probeCodexConfig(filePath) {
100
+ const raw = safeReadFile(filePath);
101
+ if (raw == null) return false;
102
+ return CODEX_BASE_URL_UNIC_RE.test(raw);
103
+ }
104
+
105
+ /**
106
+ * Presence-only check against a secrets file. Reads the file once, tests for the substring, and
107
+ * returns a boolean. The raw content is never retained, logged, or returned — it falls out of
108
+ * scope as soon as this function returns. A read failure (missing file, permission error, etc.)
109
+ * is swallowed and treated as "no hit"; the error itself is never surfaced (it could, in theory,
110
+ * embed a path — this function does not let it propagate at all).
111
+ */
112
+ function probeKiloSecretsPresence(filePath) {
113
+ try {
114
+ const raw = fs.readFileSync(filePath, 'utf8');
115
+ return raw.includes(UNIC_TOKEN);
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Checks whether the Codex model catalog exists and, if so, whether it lists `unic-vision`.
123
+ * A missing/unreadable/malformed catalog is never an error — it degrades to "unknown" and the
124
+ * caller treats that as aliasAvailable: false with an explanatory advisory.
125
+ */
126
+ function checkCodexVisionAlias(catalogPath) {
127
+ const json = safeReadJson(catalogPath);
128
+ if (json == null) {
129
+ return { checked: false, aliasAvailable: false };
130
+ }
131
+ const models = Array.isArray(json?.models)
132
+ ? json.models
133
+ : Array.isArray(json)
134
+ ? json
135
+ : [];
136
+ const aliasAvailable = models.some((entry) => {
137
+ const slug = entry && typeof entry === 'object' ? entry.slug ?? entry.id ?? entry.name : null;
138
+ return slug === VISION_MODEL_ALIAS;
139
+ });
140
+ return { checked: true, aliasAvailable };
141
+ }
142
+
143
+ /**
144
+ * Runs all seven probes in the documented order and returns the output contract object. Never
145
+ * throws — any unexpected error in an individual probe is contained so the overall result still
146
+ * reflects whatever was determined so far.
147
+ *
148
+ * @param {{ rootDir?: string, homeDir?: string }} [options]
149
+ */
150
+ export function detectUnicGateway(options = {}) {
151
+ const homeDir = resolveHomeDir(options.homeDir);
152
+ const rootDir = resolveRootDir(options.rootDir);
153
+
154
+ const sources = [];
155
+ const recordHit = (hit, label) => {
156
+ if (hit && !sources.includes(label)) sources.push(label);
157
+ };
158
+
159
+ const probes = [
160
+ () => recordHit(probeEnvVar('ANTHROPIC_BASE_URL'), 'env'),
161
+ () => recordHit(probeEnvVar('OPENAI_BASE_URL'), 'env'),
162
+ () => recordHit(probeClaudeSettings(path.join(rootDir, '.claude', 'settings.json')), 'claude-settings'),
163
+ () => recordHit(probeClaudeSettings(path.join(homeDir, '.claude', 'settings.json')), 'claude-settings'),
164
+ () => recordHit(probeCodexConfig(path.join(rootDir, '.codex', 'config.toml')), 'codex'),
165
+ () => recordHit(probeCodexConfig(path.join(homeDir, '.codex', 'config.toml')), 'codex'),
166
+ () => recordHit(probeKiloSecretsPresence(path.join(homeDir, '.kilocode', 'secrets.json')), 'kilo'),
167
+ ];
168
+ for (const probe of probes) {
169
+ try {
170
+ probe();
171
+ } catch {
172
+ // A single probe failing must never abort detection or throw; skip and continue.
173
+ }
174
+ }
175
+
176
+ const unicMode = sources.length > 0;
177
+ const source = sources[0] ?? null;
178
+
179
+ let catalogInfo = { checked: false, aliasAvailable: false };
180
+ try {
181
+ catalogInfo = checkCodexVisionAlias(path.join(homeDir, '.codex', 'model-catalog.json'));
182
+ } catch {
183
+ catalogInfo = { checked: false, aliasAvailable: false };
184
+ }
185
+
186
+ const result = {
187
+ unicMode,
188
+ source,
189
+ sources,
190
+ visionModel: VISION_MODEL_ALIAS,
191
+ aliasAvailable: catalogInfo.aliasAvailable,
192
+ };
193
+
194
+ const advisoryParts = [];
195
+ if (!unicMode) {
196
+ advisoryParts.push(
197
+ 'No tool on this machine is pointed at the UNIC gateway (unicjsc.com); UNIC routing stays disabled.',
198
+ );
199
+ }
200
+ if (catalogInfo.checked && !catalogInfo.aliasAvailable) {
201
+ advisoryParts.push(`${VISION_MODEL_ALIAS} not listed in the Codex model catalog; vision requests may fall back.`);
202
+ }
203
+ if (advisoryParts.length > 0) {
204
+ result.advisory = advisoryParts.join(' ');
205
+ }
206
+
207
+ return result;
208
+ }
209
+
210
+ function formatHuman(result) {
211
+ const lines = [
212
+ `unicMode: ${result.unicMode}`,
213
+ `source: ${result.source ?? '-'}`,
214
+ `sources: ${result.sources.length > 0 ? result.sources.join(', ') : '-'}`,
215
+ `visionModel: ${result.visionModel}`,
216
+ `aliasAvailable: ${result.aliasAvailable}`,
217
+ ];
218
+ if (result.advisory) lines.push(`advisory: ${result.advisory}`);
219
+ return lines.join('\n');
220
+ }
221
+
222
+ function safeFallbackResult() {
223
+ return {
224
+ unicMode: false,
225
+ source: null,
226
+ sources: [],
227
+ visionModel: VISION_MODEL_ALIAS,
228
+ aliasAvailable: false,
229
+ advisory: 'unic-gateway detection failed unexpectedly; defaulting to unicMode: false.',
230
+ };
231
+ }
232
+
233
+ function main() {
234
+ const args = process.argv.slice(2);
235
+ const jsonMode = args.includes('--json');
236
+
237
+ let result;
238
+ try {
239
+ result = detectUnicGateway();
240
+ } catch {
241
+ result = safeFallbackResult();
242
+ }
243
+
244
+ try {
245
+ if (jsonMode) {
246
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
247
+ } else {
248
+ process.stdout.write(`${formatHuman(result)}\n`);
249
+ }
250
+ } catch {
251
+ // Even output formatting must never throw the process into a non-zero exit.
252
+ }
253
+
254
+ process.exitCode = 0;
255
+ }
256
+
257
+ const isMainModule = (() => {
258
+ try {
259
+ return path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1] ?? '');
260
+ } catch {
261
+ return false;
262
+ }
263
+ })();
264
+
265
+ if (isMainModule) {
266
+ try {
267
+ main();
268
+ } catch {
269
+ process.exitCode = 0;
270
+ }
271
+ }
@@ -162,6 +162,14 @@ When the same file or symbol fails `debugLoopThreshold` (default: 2) times in on
162
162
 
163
163
  This is internal orchestration — end users do not need to know about tiers, thresholds, or escalation. The AI handles routing transparently.
164
164
 
165
+ ### Vision lane (capability, not a cost tier)
166
+
167
+ `unic-vision` is a **capability lane**, not a fourth cost tier — it is orthogonal to lite/code/smart above and never appears as a row in the tier table. `unic-code` and `unic-smart` cannot read images on the UNIC gateway; they must never guess at image contents.
168
+
169
+ - **Gateway detection**: seeing `unicjsc.com` in **any** tool's base URL — Claude Code env, Claude settings, Codex config, Kilo, or `OPENAI_BASE_URL` — activates UNIC routing.
170
+ - **Enforcement**: every image (pasted, local file path, or URL) must be analysed by the `ukit-vision-analyst` agent running on `unic-vision` before any related edit happens. `Edit`/`Write` are **hard-blocked** until an analysis receipt exists for every pending image; `Read`/`Grep`/`Glob`/`Bash` stay unblocked so the analyst itself can see the image and write its receipt.
171
+ - This is internal orchestration — end users never invoke a vision command directly; `ukit install` plus natural language remains the whole surface. No new commands.
172
+
165
173
  ## Project Snapshot
166
174
 
167
175
  - Project: {{project.name}} | Root: {{project.root}}
@@ -82,6 +82,7 @@
82
82
  "advisorEnabled": true,
83
83
  "contracts": {
84
84
  "tiny-fix": {
85
+ "modelTier": "lite",
85
86
  "maxReadPasses": 0,
86
87
  "maxContextPulls": 0,
87
88
  "verificationPolicy": "minimal-or-targeted",
@@ -89,6 +90,7 @@
89
90
  "delegationPolicy": "disallow"
90
91
  },
91
92
  "local-fix": {
93
+ "modelTier": "code",
92
94
  "maxReadPasses": 1,
93
95
  "maxContextPulls": 1,
94
96
  "verificationPolicy": "targeted-if-covered",
@@ -96,6 +98,7 @@
96
98
  "delegationPolicy": "disallow"
97
99
  },
98
100
  "local-build": {
101
+ "modelTier": "code",
99
102
  "maxReadPasses": 2,
100
103
  "maxContextPulls": 1,
101
104
  "verificationPolicy": "targeted-if-covered",
@@ -103,12 +106,14 @@
103
106
  "delegationPolicy": "disallow-by-default"
104
107
  },
105
108
  "find-cause": {
109
+ "modelTier": "code",
106
110
  "maxReadPassesBeforeReassess": 3,
107
111
  "verificationPolicy": "root-cause-then-targeted",
108
112
  "completionRule": "never-claim-fixed-without-write-and-verification",
109
113
  "delegationPolicy": "allow-specialized-debug-lane"
110
114
  },
111
115
  "shared-edit": {
116
+ "modelTier": "code",
112
117
  "maxReadPasses": 2,
113
118
  "maxContextPulls": 2,
114
119
  "verificationPolicy": "targeted-then-widen-on-risk",
@@ -116,6 +121,7 @@
116
121
  "delegationPolicy": "allow-qualified-sidecar"
117
122
  },
118
123
  "map-impact": {
124
+ "modelTier": "code",
119
125
  "maxReadPasses": 3,
120
126
  "maxContextPulls": 3,
121
127
  "verificationPolicy": "impact-first-then-targeted-then-widen-on-risk",
@@ -123,10 +129,29 @@
123
129
  "delegationPolicy": "allow-impact-sidecar"
124
130
  },
125
131
  "review-release": {
132
+ "modelTier": "smart",
126
133
  "verificationPolicy": "evidence-first",
127
134
  "completionRule": "report-findings-not-implementation",
128
135
  "delegationPolicy": "allow-review-sidecar"
129
136
  }
137
+ },
138
+ "modelTiers": {
139
+ "lite": { "claudeModel": "claude-haiku-4-5", "genericModel": "unic-lite" },
140
+ "code": { "claudeModel": "claude-sonnet-4-6", "genericModel": "unic-code" },
141
+ "smart": { "claudeModel": "claude-opus-4-6", "genericModel": "unic-smart" },
142
+ "vision": {
143
+ "claudeModel": "unic-vision",
144
+ "genericModel": "unic-vision",
145
+ "fallbackModel": "claude-sonnet-4-6",
146
+ "capabilityTier": true,
147
+ "note": "Capability tier, not a cost tier. Orthogonal to lite/code/smart — never insert into escalation.tierOrder. fallbackModel is used when unicMode is off."
148
+ }
149
+ },
150
+ "escalation": {
151
+ "enabled": true,
152
+ "debugLoopThreshold": 2,
153
+ "tierOrder": ["lite", "code", "smart"],
154
+ "cap": "smart"
130
155
  }
131
156
  },
132
157
  "memory": {
@@ -199,6 +224,14 @@
199
224
  "enabled": true,
200
225
  "smallTaskModel": "unic-lite",
201
226
  "smallTaskAgent": "ukit-small-task-maintainer",
227
+ "visionEnabled": true,
228
+ "visionModel": "unic-vision",
229
+ "visionAgent": "ukit-vision-analyst",
230
+ "visionCacheDir": ".ukit/storage/cache/vision",
231
+ "visionMaxImages": 3,
232
+ "visionMaxBytes": 10485760,
233
+ "visionRetain": 20,
234
+ "visionSessionTtlHours": 24,
202
235
  "smallTaskUseCases": [
203
236
  "task-cleanup",
204
237
  "compact-decision",