@ngockhoale/ukit 1.6.3 → 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.
@@ -0,0 +1,177 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: hard-enforce UKit handoff model-tier contract.
3
+ #
4
+ # create (planning) and review must be authored by the strong tier (opus/unic-smart);
5
+ # implement must be authored by at least the code tier (sonnet/unic-code), never lite.
6
+ #
7
+ # Applies identically whether triggered via standalone /ukit:handoff-create,
8
+ # /ukit:handoff-implement, /ukit:handoff-review, or the combined /ukit:handoff-fullstack —
9
+ # the gate is keyed on file content, not on which command was run.
10
+ #
11
+ # Always hard-blocks (exit 2). No advisory/soft mode — the user explicitly asked for
12
+ # strict enforcement of this contract.
13
+
14
+ INPUT=$(cat)
15
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
16
+
17
+ INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ const payload = (() => {
22
+ try { return JSON.parse(process.env.INPUT || '{}'); } catch { return {}; }
23
+ })();
24
+ const projectRoot = process.env.PROJECT_ROOT;
25
+ const toolName = payload?.tool_name || '';
26
+ const input = payload?.tool_input || {};
27
+
28
+ function block(message) {
29
+ process.stderr.write(`BLOCKED (handoff model-tier guard): ${message}\n`);
30
+ process.exit(2);
31
+ }
32
+
33
+ function tierOf(model) {
34
+ if (!model) return null;
35
+ if (/unic-vision/i.test(model)) return 'vision';
36
+ if (/opus|unic-smart/i.test(model)) return 'smart';
37
+ if (/sonnet|unic-code/i.test(model)) return 'code';
38
+ if (/haiku|unic-lite/i.test(model)) return 'lite';
39
+ return 'unknown';
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
+
45
+ function extractField(text, name) {
46
+ const matches = [...text.matchAll(new RegExp(name + ':\\s*(.+)', 'g'))];
47
+ if (matches.length === 0) return '';
48
+ return matches[matches.length - 1][1].trim();
49
+ }
50
+
51
+ if (toolName === 'Write' || toolName === 'Edit') {
52
+ const filePath = String(input.file_path || '');
53
+ if (!filePath) process.exit(0);
54
+
55
+ const relPath = path.relative(projectRoot, filePath).replace(/\\/g, '/');
56
+ const isPlan = relPath === 'docs/AI_HANDOFF/PLAN.md';
57
+ const taskMatch = relPath.match(/^docs\/AI_HANDOFF\/tasks\/(TASK-\d+)\.md$/);
58
+ if (!isPlan && !taskMatch) process.exit(0);
59
+
60
+ const fileExists = fs.existsSync(filePath);
61
+ const currentContent = fileExists ? fs.readFileSync(filePath, 'utf8') : '';
62
+
63
+ function resultingContent() {
64
+ if (toolName === 'Write') {
65
+ return typeof input.content === 'string' ? input.content : currentContent;
66
+ }
67
+ const oldStr = input.old_string;
68
+ const newStr = input.new_string;
69
+ if (typeof oldStr !== 'string' || typeof newStr !== 'string') return currentContent;
70
+ return input.replace_all
71
+ ? currentContent.split(oldStr).join(newStr)
72
+ : currentContent.replace(oldStr, newStr);
73
+ }
74
+ const newContent = resultingContent();
75
+
76
+ if (isPlan && /## Planner Report/.test(newContent)) {
77
+ const plannerModel = extractField(newContent, 'PLANNER_MODEL');
78
+ if (!plannerModel || /^unknown$/i.test(plannerModel)) {
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.');
80
+ }
81
+ if (tierOf(plannerModel) === 'vision') {
82
+ block(`PLANNER_MODEL "${plannerModel}" ${VISION_LANE_MESSAGE}`);
83
+ }
84
+ if (tierOf(plannerModel) !== 'smart') {
85
+ block(`PLANNER_MODEL "${plannerModel}" is not strong/opus tier. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart).`);
86
+ }
87
+ }
88
+
89
+ if (taskMatch) {
90
+ const taskId = taskMatch[1];
91
+ const planPath = path.join(projectRoot, 'docs/AI_HANDOFF/PLAN.md');
92
+
93
+ const isFreshTaskFile = !fileExists && !/## Executor Report|## Reviewer Verdict/.test(newContent);
94
+ if (isFreshTaskFile) {
95
+ const planContent = fs.existsSync(planPath) ? fs.readFileSync(planPath, 'utf8') : '';
96
+ const plannerModel = extractField(planContent, 'PLANNER_MODEL');
97
+ if (!plannerModel || tierOf(plannerModel) !== 'smart') {
98
+ block(`Cannot create ${taskId}.md — PLAN.md has no valid smart-tier PLANNER_MODEL yet. Run planning via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) first.`);
99
+ }
100
+ }
101
+
102
+ if (/## Executor Report/.test(newContent) && !/## Executor Report/.test(currentContent)) {
103
+ const executorModel = extractField(newContent, 'EXECUTOR_MODEL');
104
+ if (!executorModel || /^unknown$/i.test(executorModel)) {
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.`);
106
+ }
107
+ if (tierOf(executorModel) === 'vision') {
108
+ block(`${taskId}: EXECUTOR_MODEL "${executorModel}" ${VISION_LANE_MESSAGE}`);
109
+ }
110
+ if (tierOf(executorModel) === 'lite') {
111
+ block(`${taskId}: EXECUTOR_MODEL "${executorModel}" is lite tier. Implementation must run on at least sonnet/unic-code, never haiku/unic-lite.`);
112
+ }
113
+ }
114
+
115
+ if (/## Reviewer Verdict/.test(newContent) && !/## Reviewer Verdict/.test(currentContent)) {
116
+ const executorModel = extractField(currentContent, 'EXECUTOR_MODEL') || extractField(newContent, 'EXECUTOR_MODEL');
117
+ const reviewerModel = extractField(newContent, 'REVIEWER_MODEL');
118
+ if (!reviewerModel || /^unknown$/i.test(reviewerModel)) {
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.`);
120
+ }
121
+ if (tierOf(reviewerModel) === 'vision') {
122
+ block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" ${VISION_LANE_MESSAGE}`);
123
+ }
124
+ if (tierOf(reviewerModel) !== 'smart') {
125
+ block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" is not strong/opus tier. Review must run via Agent tool subagent_type: "code-reviewer" (opus/unic-smart).`);
126
+ }
127
+ if (executorModel && reviewerModel && executorModel.toLowerCase() === reviewerModel.toLowerCase()) {
128
+ block(`${taskId}: REVIEWER_MODEL ("${reviewerModel}") matches EXECUTOR_MODEL — reviewer must differ from executor. Re-run review through a different subagent/model.`);
129
+ }
130
+ }
131
+ }
132
+ process.exit(0);
133
+ }
134
+
135
+ if (toolName === 'Bash') {
136
+ const command = String(input.command || '');
137
+ if (!/\bgit\s+push\b/.test(command)) process.exit(0);
138
+
139
+ const activePath = path.join(projectRoot, 'docs/AI_HANDOFF/ACTIVE.md');
140
+ const indexPath = path.join(projectRoot, 'docs/AI_HANDOFF/INDEX.md');
141
+ if (!fs.existsSync(activePath) || !fs.existsSync(indexPath)) process.exit(0); // no handoff cycle here
142
+
143
+ const taskIds = [...fs.readFileSync(indexPath, 'utf8').matchAll(/TASK-\d+/g)]
144
+ .map((m) => m[0])
145
+ .filter((v, i, a) => a.indexOf(v) === i);
146
+
147
+ const problems = [];
148
+ for (const taskId of taskIds) {
149
+ const taskPath = path.join(projectRoot, `docs/AI_HANDOFF/tasks/${taskId}.md`);
150
+ if (!fs.existsSync(taskPath)) continue;
151
+ const text = fs.readFileSync(taskPath, 'utf8');
152
+ if (!text.includes('## Reviewer Verdict')) continue; // not yet reviewed, not this push's concern
153
+
154
+ const executorModel = extractField(text, 'EXECUTOR_MODEL');
155
+ const reviewerModel = extractField(text, 'REVIEWER_MODEL');
156
+
157
+ if (!executorModel || /^unknown$/i.test(executorModel)) problems.push(`${taskId}: EXECUTOR_MODEL missing/unknown`);
158
+ else if (tierOf(executorModel) === 'lite') problems.push(`${taskId}: EXECUTOR_MODEL "${executorModel}" is lite tier`);
159
+
160
+ if (!reviewerModel || /^unknown$/i.test(reviewerModel)) problems.push(`${taskId}: REVIEWER_MODEL missing/unknown`);
161
+ else if (tierOf(reviewerModel) !== 'smart') problems.push(`${taskId}: REVIEWER_MODEL "${reviewerModel}" is not opus/unic-smart tier`);
162
+
163
+ if (executorModel && reviewerModel && executorModel.toLowerCase() === reviewerModel.toLowerCase()) {
164
+ problems.push(`${taskId}: REVIEWER_MODEL == EXECUTOR_MODEL`);
165
+ }
166
+ }
167
+
168
+ if (problems.length > 0) {
169
+ block(`git push refused — handoff model-tier contract violated:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
170
+ }
171
+ process.exit(0);
172
+ }
173
+
174
+ process.exit(0);
175
+ NODE
176
+
177
+ exit $?
@@ -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 $?
@@ -0,0 +1,155 @@
1
+ #!/bin/bash
2
+ # UserPromptSubmit hook: detect image input (pasted / local path / URL) and arm the
3
+ # TASK-008 write gate by writing pending-<sha>.json markers via extract-image.mjs.
4
+ #
5
+ # FAILS OPEN, unconditionally: this hook must never block the user's prompt. Any
6
+ # failure (malformed stdin, missing extractor, node crash) degrades to "no hint,
7
+ # no markers" and exit 0. This is the deliberate opposite of the TASK-008 gate,
8
+ # which fails closed on Edit/Write.
9
+ #
10
+ # Detection only — never decodes an image, never writes an image file. Marker
11
+ # writing is delegated entirely to `extract-image.mjs --mark-pending`; this hook
12
+ # never computes a sha itself (PLAN §3.1).
13
+ #
14
+ # Runs on EVERY prompt, 8s budget: one shell-out to extract-image.mjs
15
+ # --detect --mark-pending --json, no decoding.
16
+
17
+ INPUT=$(cat)
18
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
19
+
20
+ INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { execFileSync } = require('child_process');
24
+ const { pathToFileURL } = require('url');
25
+
26
+ (async () => {
27
+ const payload = (() => {
28
+ try {
29
+ const parsed = JSON.parse(process.env.INPUT || '');
30
+ return parsed && typeof parsed === 'object' ? parsed : {};
31
+ } catch {
32
+ return {};
33
+ }
34
+ })();
35
+ const projectRoot = process.env.PROJECT_ROOT;
36
+
37
+ function extractPromptText(p) {
38
+ const candidates = [p?.prompt, p?.user_prompt, p?.text];
39
+ for (const candidate of candidates) {
40
+ if (typeof candidate === 'string' && candidate.trim()) return candidate;
41
+ }
42
+ return '';
43
+ }
44
+
45
+ const promptText = extractPromptText(payload);
46
+
47
+ // URL images are checked first and stripped out so the local-path regex never
48
+ // re-matches the tail of a URL (case c is a hint-only lane — no download here).
49
+ const URL_IMAGE_RE = /https?:\/\/\S+\.(?:png|jpe?g|gif|webp)\b/gi;
50
+ const LOCAL_PATH_RE = /\S+\.(?:png|jpe?g|gif|webp|bmp)\b/gi;
51
+
52
+ const urlMatches = promptText.match(URL_IMAGE_RE) || [];
53
+ const textWithoutUrls = promptText.replace(URL_IMAGE_RE, ' ');
54
+ const localMatches = textWithoutUrls.match(LOCAL_PATH_RE) || [];
55
+
56
+ // Shell out to the single owner of image detection + marker writing. Runs on every
57
+ // prompt (cheap: --detect --mark-pending, never decodes). All three input forms are
58
+ // armed here, not just pasted images: a path or URL that only produced hint text would
59
+ // leave Edit/Write ungated, which is exactly the "analyse blind" case this cycle exists
60
+ // to prevent. --ref hashes a local file's bytes / a URL string inside the extractor.
61
+ let markedPasted = 0;
62
+ let markedPath = 0;
63
+ let markedUrl = 0;
64
+ const extractorPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'extract-image.mjs');
65
+ if (fs.existsSync(extractorPath)) {
66
+ const args = [extractorPath, '--detect', '--mark-pending', '--json'];
67
+ if (typeof payload?.transcript_path === 'string' && payload.transcript_path.trim()) {
68
+ args.push('--session', payload.transcript_path.trim());
69
+ }
70
+ if (typeof payload?.session_id === 'string' && payload.session_id.trim()) {
71
+ args.push('--session-id', payload.session_id.trim());
72
+ }
73
+ for (const ref of [...localMatches, ...urlMatches]) {
74
+ args.push('--ref', ref);
75
+ }
76
+ try {
77
+ const out = execFileSync('node', args, {
78
+ cwd: projectRoot,
79
+ encoding: 'utf8',
80
+ timeout: 6000,
81
+ });
82
+ const parsed = JSON.parse(out);
83
+ const images = Array.isArray(parsed?.images) ? parsed.images : [];
84
+ // Ref markers carry a `source`; pasted ones do not. Counting this way stays correct
85
+ // when a named path does not exist on disk and the extractor drops it.
86
+ markedPasted = images.filter((img) => !img?.source).length;
87
+ markedPath = images.filter((img) => img?.source === 'path').length;
88
+ markedUrl = images.filter((img) => img?.source === 'url').length;
89
+ } catch (err) {
90
+ process.stderr.write(`vision-router: extractor call skipped (${err?.message ?? err})\n`);
91
+ }
92
+ }
93
+
94
+ // `cases` reflects what the PROMPT mentioned; `armed` reflects what actually got a
95
+ // pending marker. They differ when a named path does not resolve from the project root
96
+ // — still worth a hint, but the gate is not armed and must not be claimed to be.
97
+ const cases = [];
98
+ if (markedPasted > 0) cases.push('pasted image');
99
+ if (localMatches.length > 0) cases.push('local file path');
100
+ if (urlMatches.length > 0) cases.push('image URL');
101
+ const armed = markedPasted + markedPath + markedUrl;
102
+
103
+ if (cases.length === 0) {
104
+ process.exit(0);
105
+ return;
106
+ }
107
+
108
+ // Optional: name the concrete vision model when the UNIC gateway is active
109
+ // (TASK-003). Never fatal — a missing/broken gateway module just omits the note.
110
+ let unicNote = '';
111
+ try {
112
+ const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
113
+ if (fs.existsSync(gatewayPath)) {
114
+ const mod = await import(pathToFileURL(gatewayPath).href);
115
+ if (typeof mod.detectUnicGateway === 'function') {
116
+ const result = mod.detectUnicGateway({ rootDir: projectRoot });
117
+ if (result?.unicMode) {
118
+ unicNote = ` (UNIC gateway active — ${result.visionModel} routes through it.)`;
119
+ }
120
+ }
121
+ }
122
+ } catch {
123
+ unicNote = '';
124
+ }
125
+
126
+ const lines = [
127
+ `UKIT VISION ROUTE — image input detected (${cases.join(', ')}).`,
128
+ armed > 0
129
+ ? 'unic-code / unic-smart cannot read images on this gateway, and Edit/Write is now GATED\nuntil a vision analysis exists. Do this before anything else:'
130
+ : 'unic-code / unic-smart cannot read images on this gateway. Do this before anything else:',
131
+ ' 1. node .claude/ukit/index/extract-image.mjs --json',
132
+ ' 2. Agent(subagent_type: "ukit-vision-analyst") [model: unic-vision]',
133
+ ' Pass the ABSOLUTE paths from images[].path as text — a subagent does NOT',
134
+ ' inherit image blocks; it can only Read files.',
135
+ ' 3. Continue the real task using the returned description.',
136
+ ];
137
+ if (markedUrl > 0) {
138
+ lines.push(' Image URL detected: ukit-vision-analyst downloads it with Bash into');
139
+ lines.push(' .ukit/storage/cache/vision/, then Reads the downloaded file.');
140
+ }
141
+ if (localMatches.length > markedPath) {
142
+ lines.push(' A named image path did not resolve from the project root — resolve it before');
143
+ lines.push(' analysing, and do not guess at its contents.');
144
+ }
145
+ if (unicNote) lines.push(unicNote);
146
+
147
+ process.stdout.write(`${lines.join('\n')}\n`);
148
+ process.exit(0);
149
+ })().catch((err) => {
150
+ process.stderr.write(`vision-router: unexpected error (${err?.message ?? err})\n`);
151
+ process.exit(0);
152
+ });
153
+ NODE
154
+
155
+ exit 0
@@ -77,6 +77,16 @@
77
77
  "type": "command",
78
78
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
79
79
  "timeout": 8
80
+ },
81
+ {
82
+ "type": "command",
83
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\"",
84
+ "timeout": 8
85
+ },
86
+ {
87
+ "type": "command",
88
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-gate.sh\"",
89
+ "timeout": 8
80
90
  }
81
91
  ]
82
92
  },
@@ -102,6 +112,11 @@
102
112
  "type": "command",
103
113
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.sh\"",
104
114
  "timeout": 8
115
+ },
116
+ {
117
+ "type": "command",
118
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\"",
119
+ "timeout": 8
105
120
  }
106
121
  ]
107
122
  }
@@ -135,6 +150,11 @@
135
150
  "type": "command",
136
151
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
137
152
  "timeout": 8
153
+ },
154
+ {
155
+ "type": "command",
156
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-router.sh\"",
157
+ "timeout": 8
138
158
  }
139
159
  ]
140
160
  }