@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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 1.6.5 - 2026-08-02
6
+
7
+ ### Added
8
+
9
+ - **Enforced `unic-vision` image lane.** Images are now mechanically routed to a vision-capable model instead of being guessed at by `unic-code`/`unic-smart`, which cannot read images on the UNIC gateway.
10
+ - `.claude/ukit/index/extract-image.mjs` — decodes pasted images out of the session transcript to disk (a spawned subagent receives text only and does not inherit image blocks), and writes content-addressed `pending-<sha>.json` markers. Single owner of the sha256; both the marker and the image filename come from the same hash.
11
+ - `.claude/hooks/vision-router.sh` (`UserPromptSubmit`) — detects all three input forms (pasted, local path, image URL) and arms a marker for each. Fails OPEN: a failure here degrades to no hint and no markers, never a wedged prompt.
12
+ - `.claude/hooks/vision-gate.sh` (`PreToolUse`, `Edit|Write` only) — hard-blocks the write (exit 2) until an `analyzed-<sha>.json` receipt exists whose self-reported model is vision-capable. A receipt from `unic-code`, `unic-smart`, `sonnet`, or `opus` is treated as ABSENT and still blocks. Fails CLOSED, and gateway detection that is missing or broken DENIES the fallback-model exception rather than granting it. `Read`/`Grep`/`Glob`/`Bash` are never gated — the vision analyst needs them to clear the gate.
13
+ - `ukit-vision-analyst` agent — pinned to `unic-vision`, the only lane permitted to interpret images. Reports findings only; it has no `Edit`/`Write`.
14
+ - `.claude/ukit/index/unic-gateway.mjs` — detects a `unicjsc.com` gateway across Claude Code env, Codex config, Kilo config, and `OPENAI_BASE_URL`. Only ever tests for substring presence; never reads credential file contents into output.
15
+
16
+ ### Changed
17
+
18
+ - `orchestration.escalation` now has real consumers. `route-task.mjs` reads `debugLoopThreshold`, `tierOrder`, and `cap` from config instead of hardcoded literals, and emits `escalatedTier` alongside `modelTier` — one rung up, capped at `smart`. The same literals remain as fallbacks, so projects that never opted in behave identically.
19
+ - `orchestration.modelTiers` documents all four UNIC models. `vision` is a capability lane orthogonal to the lite/code/smart cost tiers, never a fourth tier, and is stripped from any configured `tierOrder`.
20
+ - `handoff-model-guard.sh`: `tierOf()` hardened against vision/unknown model names.
21
+
5
22
  ## 1.6.4 - 2026-07-27
6
23
 
7
24
  ### Added
@@ -819,6 +819,17 @@ items:
819
819
  packs:
820
820
  - core
821
821
 
822
+ - id: agent-ukit-vision-analyst
823
+ type: agent
824
+ sourceTemplate: .claude/agents/ukit-vision-analyst.md
825
+ targetPath: .claude/agents/ukit-vision-analyst.md
826
+ requires: []
827
+ mergeStrategy: overwrite_with_backup
828
+ variables: []
829
+ enabledByDefault: true
830
+ packs:
831
+ - core
832
+
822
833
  - id: command-handoff-create
823
834
  type: command
824
835
  sourceTemplate: .claude/commands/ukit/handoff-create.md
@@ -1037,6 +1048,32 @@ items:
1037
1048
  packs:
1038
1049
  - core
1039
1050
 
1051
+ - id: hook-vision-router
1052
+ type: hook
1053
+ sourceTemplate: .claude/hooks/vision-router.sh
1054
+ targetPath: .claude/hooks/vision-router.sh
1055
+ requires:
1056
+ - ukit-index-extract-image-script
1057
+ - ukit-index-unic-gateway-script
1058
+ mergeStrategy: overwrite_with_backup
1059
+ variables: []
1060
+ enabledByDefault: true
1061
+ packs:
1062
+ - core
1063
+
1064
+ - id: hook-vision-gate
1065
+ type: hook
1066
+ sourceTemplate: .claude/hooks/vision-gate.sh
1067
+ targetPath: .claude/hooks/vision-gate.sh
1068
+ requires:
1069
+ - ukit-index-extract-image-script
1070
+ - ukit-index-unic-gateway-script
1071
+ mergeStrategy: overwrite_with_backup
1072
+ variables: []
1073
+ enabledByDefault: true
1074
+ packs:
1075
+ - core
1076
+
1040
1077
  - id: hook-compress-output
1041
1078
  type: hook
1042
1079
  sourceTemplate: .claude/hooks/compress-output.sh
@@ -1266,6 +1303,26 @@ items:
1266
1303
  packs:
1267
1304
  - core
1268
1305
 
1306
+ - id: ukit-index-unic-gateway-script
1307
+ type: config
1308
+ sourceTemplate: .claude/ukit/index/unic-gateway.mjs
1309
+ targetPath: .claude/ukit/index/unic-gateway.mjs
1310
+ mergeStrategy: overwrite_with_backup
1311
+ variables: []
1312
+ enabledByDefault: true
1313
+ packs:
1314
+ - core
1315
+
1316
+ - id: ukit-index-extract-image-script
1317
+ type: config
1318
+ sourceTemplate: .claude/ukit/index/extract-image.mjs
1319
+ targetPath: .claude/ukit/index/extract-image.mjs
1320
+ mergeStrategy: overwrite_with_backup
1321
+ variables: []
1322
+ enabledByDefault: true
1323
+ packs:
1324
+ - core
1325
+
1269
1326
  - id: ukit-index-route-task-script
1270
1327
  type: config
1271
1328
  sourceTemplate: .claude/ukit/index/route-task.mjs
@@ -1274,6 +1331,8 @@ items:
1274
1331
  - ukit-index-core-lib
1275
1332
  - ukit-index-cache-utils-script
1276
1333
  - ukit-index-route-catalog-script
1334
+ - ukit-index-unic-gateway-script
1335
+ - ukit-index-extract-image-script
1277
1336
  mergeStrategy: overwrite_with_backup
1278
1337
  variables: []
1279
1338
  enabledByDefault: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "1.6.4",
3
+ "version": "1.6.5",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, Antigravity, OpenAI Codex, and OpenCode.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -164,6 +164,24 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
164
164
  delegationPolicy: 'allow-review-sidecar',
165
165
  },
166
166
  },
167
+ modelTiers: {
168
+ lite: { claudeModel: 'claude-haiku-4-5', genericModel: 'unic-lite' },
169
+ code: { claudeModel: 'claude-sonnet-4-6', genericModel: 'unic-code' },
170
+ smart: { claudeModel: 'claude-opus-4-6', genericModel: 'unic-smart' },
171
+ vision: {
172
+ claudeModel: 'unic-vision',
173
+ genericModel: 'unic-vision',
174
+ fallbackModel: 'claude-sonnet-4-6',
175
+ capabilityTier: true,
176
+ note: 'Capability tier, not a cost tier. Orthogonal to lite/code/smart — never insert into escalation.tierOrder. fallbackModel is used when unicMode is off.',
177
+ },
178
+ },
179
+ escalation: {
180
+ enabled: true,
181
+ debugLoopThreshold: 2,
182
+ tierOrder: ['lite', 'code', 'smart'],
183
+ cap: 'smart',
184
+ },
167
185
  },
168
186
  memory: {
169
187
  enabled: true,
@@ -196,6 +214,14 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
196
214
  enabled: true,
197
215
  smallTaskModel: 'unic-lite',
198
216
  smallTaskAgent: 'ukit-small-task-maintainer',
217
+ visionEnabled: true,
218
+ visionModel: 'unic-vision',
219
+ visionAgent: 'ukit-vision-analyst',
220
+ visionCacheDir: '.ukit/storage/cache/vision',
221
+ visionMaxImages: 3,
222
+ visionMaxBytes: 10_485_760,
223
+ visionRetain: 20,
224
+ visionSessionTtlHours: 24,
199
225
  smallTaskUseCases: [
200
226
  'task-cleanup',
201
227
  'compact-decision',
@@ -0,0 +1,101 @@
1
+ ---
2
+ name: ukit-vision-analyst
3
+ description: "The only lane permitted to interpret images in this repo. Use whenever a prompt references, attaches, or points at an image (screenshot, mockup, diagram, photo of an error) and the caller needs to know what is actually in it. unic-code/unic-smart cannot read images on this gateway and must never guess at their contents — route image analysis here instead. Reports findings only; never writes product code."
4
+ model: unic-vision # real gateway model name, NOT an alias. unic-code/unic-smart cannot read images on this gateway — do NOT "fix" this to sonnet/opus.
5
+ color: magenta
6
+ tools: ["Read", "Glob", "Bash"]
7
+ ---
8
+
9
+ You are UKit's vision analyst. Your only job is to look at real image files and report what is
10
+ actually in them. You never write, edit, or refactor product code — you analyse and report.
11
+
12
+ ## 1. Role
13
+
14
+ - Analyse images (screenshots, mockups, diagrams, photos of errors) and report their content
15
+ faithfully.
16
+ - Never write product code. `Edit`/`Write` are deliberately absent from your `tools`; you report
17
+ via `Bash` (to write your receipt), you never implement.
18
+ - Stay end-user-invisible: this lane is internal UKit orchestration, not something end users invoke
19
+ by name.
20
+
21
+ ## 2. Model self-check — first action, before touching any image
22
+
23
+ Before reading any image, determine the model you are actually running on right now.
24
+
25
+ - Vision-capable means: `unic-vision`, or — when `unicMode` is off — whatever
26
+ `modelTiers.vision.fallbackModel` resolves to per
27
+ `node .claude/ukit/index/unic-gateway.mjs --json`.
28
+ - If you cannot confirm you are running on a vision-capable model, you must **refuse**: emit
29
+ `STATUS: WRONG_MODEL` in the output block below and stop immediately. Do not open, describe, or
30
+ guess at any image content.
31
+ - Never guess at image contents. A wrong-model "analysis" is worse than no analysis at all,
32
+ because it looks authoritative while being fabricated. Refusing loudly is always safer than
33
+ guessing quietly.
34
+
35
+ ## 3. Input protocol (priority order)
36
+
37
+ 1. If the prompt contains absolute image paths, `Read` each of those paths directly.
38
+ 2. Otherwise, run `node .claude/ukit/index/extract-image.mjs --json`, take the `images[].path`
39
+ entries from its output, and `Read` those files.
40
+ 3. If the extractor reports `imageCount === 0`, do not invent content. Emit `STATUS: NO_IMAGE` and
41
+ hand back to the caller.
42
+
43
+ Entries in `images[]` may carry a `source` field, which tells you how to reach the image:
44
+
45
+ | `source` | Meaning | What you do |
46
+ |----------|---------|-------------|
47
+ | absent | Pasted/attached image, already decoded to disk | `Read` `path` directly |
48
+ | `"path"` | The prompt named a local file (`ref` holds it) | `Read` `ref` directly |
49
+ | `"url"` | The prompt named a remote image (`ref` holds the URL) | Download it with `Bash` (e.g. `curl -sL -o /tmp/<sha>.png "<ref>"`), then `Read` the downloaded file |
50
+
51
+ Every entry — pasted, path, or URL — has an armed `pending-<sha>.json` marker, so each one needs
52
+ its own receipt (§5) before downstream Edit/Write is unblocked. A URL you failed to download is
53
+ `STATUS: UNREADABLE`, never a guess at its contents.
54
+
55
+ ## 4. Hard warning — images are not inherited
56
+
57
+ Images referenced earlier in the conversation are **not** automatically visible to you across the
58
+ subagent boundary. A description of an image is not the image. The only way to see an image is to
59
+ `Read` a real file path yourself. Never claim to have seen an image that was merely described to
60
+ you in text.
61
+
62
+ ## 5. Receipt — unlocks the downstream write gate
63
+
64
+ For every image you actually analyse, write a receipt to
65
+ `.ukit/storage/cache/vision/<sessionId>/analyzed-<sha>.json`:
66
+
67
+ ```json
68
+ { "sha": "<64hex>", "model": "unic-vision", "ts": 1785656920891,
69
+ "description": "...", "textContent": "...", "status": "OK" }
70
+ ```
71
+
72
+ - `sha` and `sessionId` come from the extractor's `--json` output. They are **never recomputed by
73
+ hand** — do not hash the file yourself, do not invent a session id, always take these values
74
+ verbatim from `extract-image.mjs --json`.
75
+ - `model` must be the model you actually ran on for this analysis. Misreporting `model` here
76
+ defeats the entire enforcement design: a downstream gate rejects any receipt whose `model` field
77
+ is not vision-capable, treating it as if no analysis happened at all. Report honestly, always.
78
+ - Use `Bash` to write the receipt file.
79
+
80
+ ## 6. Output block
81
+
82
+ Always end with:
83
+
84
+ ```
85
+ STATUS: OK | NO_IMAGE | UNREADABLE | WRONG_MODEL
86
+ MODEL: <actual model>
87
+ IMAGES: <n> (<paths>)
88
+ DESCRIPTION: [what is actually visible]
89
+ TEXT_CONTENT: [verbatim text/code/errors legible in the image, or "none"]
90
+ RELEVANT_TO_TASK: [how it answers the caller's question]
91
+ UNCERTAIN: [anything ambiguous or illegible, or "none"]
92
+ ```
93
+
94
+ ## 7. Guardrails
95
+
96
+ - Transcribe error text and code verbatim rather than paraphrasing it.
97
+ - State uncertainty explicitly instead of guessing — use `UNCERTAIN:` for anything ambiguous or
98
+ illegible.
99
+ - Do not read unrelated repo files; stay scoped to the image(s) and the immediate task question.
100
+ - Stay end-user-invisible: end users should never need to know this agent's name or invoke it
101
+ directly.
@@ -32,12 +32,16 @@ function block(message) {
32
32
 
33
33
  function tierOf(model) {
34
34
  if (!model) return null;
35
+ if (/unic-vision/i.test(model)) return 'vision';
35
36
  if (/opus|unic-smart/i.test(model)) return 'smart';
36
37
  if (/sonnet|unic-code/i.test(model)) return 'code';
37
38
  if (/haiku|unic-lite/i.test(model)) return 'lite';
38
39
  return 'unknown';
39
40
  }
40
41
 
42
+ const VISION_LANE_MESSAGE =
43
+ 'is a vision capability lane, not a handoff role; use unic-smart for planning/review and unic-code for implementation.';
44
+
41
45
  function extractField(text, name) {
42
46
  const matches = [...text.matchAll(new RegExp(name + ':\\s*(.+)', 'g'))];
43
47
  if (matches.length === 0) return '';
@@ -74,6 +78,9 @@ if (toolName === 'Write' || toolName === 'Edit') {
74
78
  if (!plannerModel || /^unknown$/i.test(plannerModel)) {
75
79
  block('PLANNER_MODEL missing/unknown in PLAN.md. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) and self-report its model.');
76
80
  }
81
+ if (tierOf(plannerModel) === 'vision') {
82
+ block(`PLANNER_MODEL "${plannerModel}" ${VISION_LANE_MESSAGE}`);
83
+ }
77
84
  if (tierOf(plannerModel) !== 'smart') {
78
85
  block(`PLANNER_MODEL "${plannerModel}" is not strong/opus tier. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart).`);
79
86
  }
@@ -97,6 +104,9 @@ if (toolName === 'Write' || toolName === 'Edit') {
97
104
  if (!executorModel || /^unknown$/i.test(executorModel)) {
98
105
  block(`${taskId}: EXECUTOR_MODEL missing/unknown. Implementation must run via Agent tool subagent_type: "feature-implementer" (sonnet/unic-code) and self-report its model.`);
99
106
  }
107
+ if (tierOf(executorModel) === 'vision') {
108
+ block(`${taskId}: EXECUTOR_MODEL "${executorModel}" ${VISION_LANE_MESSAGE}`);
109
+ }
100
110
  if (tierOf(executorModel) === 'lite') {
101
111
  block(`${taskId}: EXECUTOR_MODEL "${executorModel}" is lite tier. Implementation must run on at least sonnet/unic-code, never haiku/unic-lite.`);
102
112
  }
@@ -108,6 +118,9 @@ if (toolName === 'Write' || toolName === 'Edit') {
108
118
  if (!reviewerModel || /^unknown$/i.test(reviewerModel)) {
109
119
  block(`${taskId}: REVIEWER_MODEL missing/unknown. Review must run via Agent tool subagent_type: "code-reviewer" (opus/unic-smart) and self-report its model.`);
110
120
  }
121
+ if (tierOf(reviewerModel) === 'vision') {
122
+ block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" ${VISION_LANE_MESSAGE}`);
123
+ }
111
124
  if (tierOf(reviewerModel) !== 'smart') {
112
125
  block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" is not strong/opus tier. Review must run via Agent tool subagent_type: "code-reviewer" (opus/unic-smart).`);
113
126
  }
@@ -0,0 +1,230 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: hard-enforce the TASK-008 vision-analysis gate.
3
+ #
4
+ # Blocks Edit/Write whenever a pending image marker (written by vision-router.sh via
5
+ # extract-image.mjs --mark-pending) has no matching analyzed-<sha>.json receipt from a
6
+ # vision-capable model. Opposite fail direction from vision-router.sh: that hook fails
7
+ # OPEN (never wedge a prompt); this hook fails CLOSED (a broken gate must not silently
8
+ # permit blind edits). "Fails closed" applies only to this hook's own logic errors — if
9
+ # no pending marker exists at all, it exits 0 immediately, before any other logic. That
10
+ # is >99% of invocations and must stay free.
11
+ #
12
+ # Always hard-blocks (exit 2). No advisory/soft mode — matches handoff-model-guard.sh's
13
+ # posture: the user explicitly asked for strict, mechanical, non-bypassable enforcement.
14
+ # A wrong image analysis yields confidently wrong results, which is worse than no
15
+ # analysis at all.
16
+ #
17
+ # Matcher is Edit|Write ONLY. Never Read/Grep/Glob/Bash — ukit-vision-analyst itself
18
+ # needs Read to see the extracted image and Bash to write its receipt; gating those
19
+ # tools would deadlock the only lane that can clear the gate.
20
+ #
21
+ # Never recomputes a sha. extract-image.mjs (TASK-002) is the single owner of the hash;
22
+ # this hook only matches pending-<sha>.json to analyzed-<sha>.json by filename.
23
+ #
24
+ # Session-scoped and 24h-bounded: a stale marker from another session, or an abandoned
25
+ # session's marker older than 24h, must never permanently block writes.
26
+
27
+ INPUT=$(cat)
28
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
29
+
30
+ INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const { pathToFileURL } = require('url');
34
+
35
+ const MARKER_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
36
+
37
+ const payload = (() => {
38
+ try {
39
+ const parsed = JSON.parse(process.env.INPUT || '');
40
+ return parsed && typeof parsed === 'object' ? parsed : {};
41
+ } catch {
42
+ return {};
43
+ }
44
+ })();
45
+
46
+ const projectRoot = process.env.PROJECT_ROOT;
47
+ const toolName = payload?.tool_name || '';
48
+
49
+ // Gate only Edit|Write. Every other tool (Read, Grep, Glob, Bash, ...) is free — the
50
+ // vision analyst needs Read/Bash to clear this very gate. This check is independent of
51
+ // tool_input shape: the gate never inspects file_path/content, only tool_name.
52
+ if (toolName !== 'Edit' && toolName !== 'Write') {
53
+ process.exit(0);
54
+ }
55
+
56
+ const sessionId = (typeof payload.session_id === 'string' && payload.session_id.trim())
57
+ ? payload.session_id.trim()
58
+ : 'default';
59
+
60
+ const sessionDir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'vision', sessionId);
61
+
62
+ let markerNames = [];
63
+ try {
64
+ markerNames = fs.readdirSync(sessionDir).filter((name) => /^pending-.+\.json$/.test(name));
65
+ } catch {
66
+ markerNames = [];
67
+ }
68
+
69
+ // No pending marker at all: the overwhelmingly common path. Exit 0 immediately, before
70
+ // any config reads, gateway detection, or receipt checks.
71
+ if (markerNames.length === 0) {
72
+ process.exit(0);
73
+ }
74
+
75
+ function readJsonSafe(filePath) {
76
+ try {
77
+ const raw = fs.readFileSync(filePath, 'utf8');
78
+ const parsed = JSON.parse(raw);
79
+ return parsed && typeof parsed === 'object' ? parsed : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ // Best-effort: a receipt whose model equals modelTiers.vision.fallbackModel counts as
86
+ // vision-capable ONLY when unic-gateway.mjs reports unicMode:false (no real UNIC
87
+ // routing, so the analyst actually ran on the fallback model instead of unic-vision).
88
+ // In-process dynamic import — never a subprocess spawn — and never fatal: any failure
89
+ // just means the fallback exception is unavailable and only the literal "unic-vision"
90
+ // model name is accepted, which is the stricter/safer default.
91
+ function loadFallbackModel() {
92
+ const configPath = path.join(projectRoot, '.ukit', 'storage', 'config.json');
93
+ const config = readJsonSafe(configPath);
94
+ const fallback = config?.orchestration?.modelTiers?.vision?.fallbackModel;
95
+ return typeof fallback === 'string' && fallback.trim() ? fallback.trim() : null;
96
+ }
97
+
98
+ // Tri-state on purpose: true | false | null(unknown). Returning false on a detection
99
+ // FAILURE would be read as "unicMode is off", which GRANTS the fallback-model exception
100
+ // below — i.e. deleting or breaking one gitignored file would make the gate accept a
101
+ // plain code-tier receipt. Unknown must deny the exception, not grant it.
102
+ async function detectUnicModeSafe() {
103
+ try {
104
+ const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
105
+ if (!fs.existsSync(gatewayPath)) return null;
106
+ const mod = await import(pathToFileURL(gatewayPath).href);
107
+ if (typeof mod.detectUnicGateway !== 'function') return null;
108
+ const result = mod.detectUnicGateway({ rootDir: projectRoot });
109
+ return !!result?.unicMode;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ // Anchored, not a substring match: "unic-vision-mini" and "not-unic-vision" are different
116
+ // models and must not inherit the real one's capability by sharing a substring.
117
+ // ukit-vision-analyst's frontmatter pins the exact name `unic-vision`; anything else goes
118
+ // through the fallbackModel path, which requires a positive unicMode:false detection.
119
+ function isVisionCapable(model, fallbackModel, unicModeIsOff) {
120
+ if (typeof model !== 'string' || !model.trim()) return false;
121
+ const trimmed = model.trim();
122
+ if (/^unic-vision$/i.test(trimmed)) return true;
123
+ if (unicModeIsOff && fallbackModel && trimmed === fallbackModel) return true;
124
+ return false;
125
+ }
126
+
127
+ const VALID_STATUSES = new Set(['OK', 'NO_IMAGE', 'UNREADABLE']);
128
+
129
+ // A receipt whose self-reported model is not vision-capable (unic-code, unic-smart,
130
+ // sonnet, opus, ...) is treated as ABSENT and still blocks — accepting it would make the
131
+ // whole enforcement design theatre. A JSON.parse throw is caught per-receipt and turned
132
+ // into BLOCK, never an accidental skip-through.
133
+ // The gate keys its directory on the hook payload's session_id, but the analyst resolves
134
+ // its own sessionId by discovering the newest transcript. Those agree in the common case
135
+ // and diverge with concurrent sessions on one repo, which would leave the receipt in a
136
+ // sibling directory and hold the gate shut. The sha is content-addressed, so accepting a
137
+ // receipt from any session dir is safe: it still must exist and be vision-capable.
138
+ function findReceipt(sha) {
139
+ const own = path.join(sessionDir, `analyzed-${sha}.json`);
140
+ if (fs.existsSync(own)) return own;
141
+ const visionRoot = path.join(projectRoot, '.ukit', 'storage', 'cache', 'vision');
142
+ let entries = [];
143
+ try {
144
+ entries = fs.readdirSync(visionRoot, { withFileTypes: true });
145
+ } catch {
146
+ return null;
147
+ }
148
+ for (const entry of entries) {
149
+ if (!entry.isDirectory() || entry.name === sessionId) continue;
150
+ const candidate = path.join(visionRoot, entry.name, `analyzed-${sha}.json`);
151
+ if (fs.existsSync(candidate)) return candidate;
152
+ }
153
+ return null;
154
+ }
155
+
156
+ function checkReceipt(receiptPath, fallbackModel, unicModeIsOff) {
157
+ if (!receiptPath || !fs.existsSync(receiptPath)) {
158
+ return { ok: false, reason: 'no analysis receipt found' };
159
+ }
160
+ const receipt = readJsonSafe(receiptPath);
161
+ if (!receipt) {
162
+ return { ok: false, reason: 'receipt is not valid JSON' };
163
+ }
164
+ if (!isVisionCapable(receipt.model, fallbackModel, unicModeIsOff)) {
165
+ const modelLabel = typeof receipt.model === 'string' && receipt.model.trim() ? receipt.model.trim() : '(missing)';
166
+ return { ok: false, reason: `receipt model "${modelLabel}" is not vision-capable — treated as absent` };
167
+ }
168
+ const status = receipt.status;
169
+ if (!VALID_STATUSES.has(status)) {
170
+ return { ok: false, reason: `receipt status "${status}" is not a valid terminal outcome` };
171
+ }
172
+ return { ok: true };
173
+ }
174
+
175
+ (async () => {
176
+ const now = Date.now();
177
+ const fallbackModel = loadFallbackModel();
178
+ const unicMode = await detectUnicModeSafe();
179
+
180
+ const unsatisfied = [];
181
+ for (const markerName of markerNames) {
182
+ const sha = markerName.replace(/^pending-/, '').replace(/\.json$/, '');
183
+ const markerPath = path.join(sessionDir, markerName);
184
+
185
+ let markerTs = null;
186
+ const marker = readJsonSafe(markerPath);
187
+ if (marker && typeof marker.ts === 'number' && Number.isFinite(marker.ts)) {
188
+ markerTs = marker.ts;
189
+ }
190
+ if (markerTs == null) {
191
+ try {
192
+ markerTs = fs.statSync(markerPath).mtimeMs;
193
+ } catch {
194
+ markerTs = now;
195
+ }
196
+ }
197
+ // Abandoned-session bound: a marker older than 24h can never brick the repo.
198
+ if (now - markerTs > MARKER_MAX_AGE_MS) continue;
199
+
200
+ const receiptPath = findReceipt(sha);
201
+ const result = checkReceipt(receiptPath, fallbackModel, unicMode === false);
202
+ if (!result.ok) {
203
+ unsatisfied.push({ sha, reason: result.reason });
204
+ }
205
+ }
206
+
207
+ if (unsatisfied.length === 0) {
208
+ process.exit(0);
209
+ return;
210
+ }
211
+
212
+ const lines = [
213
+ 'BLOCKED (vision gate): image(s) pending analysis — Edit/Write refused.',
214
+ ...unsatisfied.map((u) => ` - sha ${u.sha}: ${u.reason}`),
215
+ 'unic-code / unic-smart cannot read images on this gateway and must not guess at their',
216
+ 'contents — a wrong image analysis yields confidently wrong results.',
217
+ 'Remedy: delegate to Agent(subagent_type: "ukit-vision-analyst") to analyse the pending',
218
+ 'image(s) first, then retry this edit.',
219
+ ];
220
+ process.stderr.write(`${lines.join('\n')}\n`);
221
+ process.exit(2);
222
+ })().catch((err) => {
223
+ // A logic error inside this hook's own JS must still fail CLOSED — the opposite fail
224
+ // direction from vision-router.sh, which fails open unconditionally.
225
+ process.stderr.write(`BLOCKED (vision gate): internal error, failing closed: ${err?.message ?? err}\n`);
226
+ process.exit(2);
227
+ });
228
+ NODE
229
+
230
+ exit $?