@ngockhoale/ukit 2.2.14 → 2.3.0

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/manifests/platform.full.yaml +11 -13
  3. package/package.json +1 -1
  4. package/src/core/executionContracts.js +130 -0
  5. package/src/core/runtimeConfig.js +13 -50
  6. package/src/index/taskRouting.js +10 -103
  7. package/templates/.claude/agents/ukit-vision-analyst.md +32 -21
  8. package/templates/.claude/hooks/context-hardcap-gate.sh +2 -2
  9. package/templates/.claude/hooks/protect-files.sh +1 -0
  10. package/templates/.claude/hooks/sensitive-data-guard.sh +269 -0
  11. package/templates/.claude/hooks/skill-router.sh +6 -0
  12. package/templates/.claude/hooks/vision-router.sh +67 -47
  13. package/templates/.claude/settings.json +17 -6
  14. package/templates/.claude/ukit/index/extract-image.mjs +18 -8
  15. package/templates/.claude/ukit/index/provision-worktree.mjs +1 -1
  16. package/templates/.claude/ukit/index/route-task.mjs +53 -2
  17. package/templates/.claude/ukit/index/unic-gateway.mjs +1 -1
  18. package/templates/.claude/ukit/runtime/execution-ledger.mjs +120 -21
  19. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +8 -5
  20. package/templates/.omp/README.md +3 -3
  21. package/templates/.omp/hooks/pre/ukit-bridge.js +15 -7
  22. package/templates/AGENTS.md +2 -2
  23. package/templates/CLAUDE.md +2 -2
  24. package/templates/ukit/storage/config.json +8 -7
  25. package/src/core/router/advisor.js +0 -42
  26. package/src/core/router/router.js +0 -180
  27. package/src/core/validation/confidence.js +0 -89
  28. package/src/core/validation/validator.js +0 -165
  29. package/templates/.claude/hooks/vision-gate.sh +0 -230
  30. package/templates/docs/INSTALL.md +0 -115
  31. package/templates/docs/STATUS.md +0 -81
  32. package/templates/docs/TASKS.md +0 -79
  33. package/templates/docs/UKIT_USAGE_GUIDE.md +0 -163
@@ -1,180 +0,0 @@
1
- const HIGH_COMPLEXITY_KEYWORDS = [
2
- 'architecture',
3
- 'architect',
4
- 'design',
5
- 'trade-off',
6
- 'tradeoff',
7
- 'security',
8
- 'compare',
9
- 'why',
10
- 'reasoning',
11
- 'scalable',
12
- 'system design',
13
- ];
14
-
15
- const LOW_COMPLEXITY_KEYWORDS = [
16
- 'rename',
17
- 'add field',
18
- 'change text',
19
- 'format',
20
- 'typo',
21
- 'update label',
22
- 'refactor name',
23
- ];
24
-
25
- const REVIEW_KEYWORDS = ['review', 'code review', 'audit'];
26
- const DEBUG_KEYWORDS = ['debug', 'bug', 'stack trace', 'error', 'crash', 'retry', 'failed attempt', 'failure'];
27
- const CODING_HINTS = ['src/', 'package.json', 'function', 'class', 'implement', 'file', '.js', '.ts', '.tsx', '.jsx', '```'];
28
-
29
- function normalize(text) {
30
- return String(text ?? '').toLowerCase();
31
- }
32
-
33
- function escapeRegExp(value) {
34
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
35
- }
36
-
37
- function matchesKeyword(text, keyword) {
38
- if (keyword.startsWith('.')) {
39
- return new RegExp(`(?:^|[\\s/\\\\])[^\\s/\\\\]+${escapeRegExp(keyword)}(?:$|[\\s)\\],.;:])`).test(text);
40
- }
41
-
42
- if (keyword.includes(' ')) {
43
- return new RegExp(`\\b${keyword.split(/\\s+/).map(escapeRegExp).join('\\\\s+')}\\b`).test(text);
44
- }
45
-
46
- return new RegExp(`\\b${escapeRegExp(keyword)}\\b`).test(text);
47
- }
48
-
49
- function countFileHints(text) {
50
- const explicitCount = Number.parseInt(normalize(text).match(/\b(\d+)\s+files?\b/)?.[1] ?? '0', 10);
51
- const pathMatches = normalize(text).match(/\b[\w./-]+\.(?:js|ts|tsx|jsx|json|md|yaml|yml)\b/g) ?? [];
52
- return Math.max(explicitCount, new Set(pathMatches).size);
53
- }
54
-
55
- export function detectComplexity(message, context = '') {
56
- const combined = normalize(`${message}\n${context}`);
57
- const wordCount = combined.split(/\s+/).filter(Boolean).length;
58
- const fileHints = countFileHints(combined);
59
- const hasRetryPattern = /\b(retry|failed|failure|attempt)\b/.test(combined);
60
- const highSignals = HIGH_COMPLEXITY_KEYWORDS.filter((keyword) => matchesKeyword(combined, keyword)).length;
61
- const lowSignals = LOW_COMPLEXITY_KEYWORDS.filter((keyword) => matchesKeyword(combined, keyword)).length;
62
-
63
- if (highSignals > 0 || fileHints > 5 || (hasRetryPattern && /\b(debug|error|crash|stack trace)\b/.test(combined))) {
64
- return 'high';
65
- }
66
-
67
- if (lowSignals > 0 || (fileHints <= 1 && wordCount <= 24 && !hasRetryPattern)) {
68
- return 'low';
69
- }
70
-
71
- return 'medium';
72
- }
73
-
74
- function detectTaskType(message, context = '', complexity = 'medium') {
75
- const combined = normalize(`${message}\n${context}`);
76
-
77
- if (DEBUG_KEYWORDS.some((keyword) => matchesKeyword(combined, keyword)) && complexity === 'high') {
78
- return 'debug_hard';
79
- }
80
-
81
- if (HIGH_COMPLEXITY_KEYWORDS.some((keyword) => matchesKeyword(combined, keyword))) {
82
- return 'reasoning';
83
- }
84
-
85
- if (REVIEW_KEYWORDS.some((keyword) => matchesKeyword(combined, keyword))) {
86
- return 'review';
87
- }
88
-
89
- if (CODING_HINTS.some((keyword) => matchesKeyword(combined, keyword))) {
90
- return 'coding';
91
- }
92
-
93
- return 'simple_chat';
94
- }
95
-
96
- function recommendTier(type, complexity) {
97
- if (type === 'simple_chat' && complexity === 'low') {
98
- return 'fast';
99
- }
100
-
101
- if (type === 'reasoning') {
102
- return 'powerful';
103
- }
104
-
105
- if (type === 'debug_hard' && complexity === 'high') {
106
- return 'powerful';
107
- }
108
-
109
- if (type === 'review') {
110
- return complexity === 'high' ? 'powerful' : 'balanced';
111
- }
112
-
113
- if (type === 'coding') {
114
- return complexity === 'high' ? 'powerful' : 'balanced';
115
- }
116
-
117
- return complexity === 'low' ? 'fast' : 'balanced';
118
- }
119
-
120
- function buildReason(type, complexity) {
121
- if (type === 'simple_chat') {
122
- return 'Short conversational request without strong coding or reasoning signals.';
123
- }
124
-
125
- if (type === 'debug_hard') {
126
- return 'Debugging request includes repeated failures or stack-trace style signals.';
127
- }
128
-
129
- if (type === 'reasoning') {
130
- return 'Request asks for architecture, comparison, trade-offs, or deeper analysis.';
131
- }
132
-
133
- if (type === 'review') {
134
- return complexity === 'high'
135
- ? 'Review request touches higher-risk or cross-cutting areas.'
136
- : 'Review request is scoped enough for the balanced tier.';
137
- }
138
-
139
- if (type === 'coding') {
140
- return complexity === 'high'
141
- ? 'Implementation spans multiple modules or difficult trade-offs.'
142
- : 'Normal implementation work is best handled by the balanced tier.';
143
- }
144
-
145
- return 'Fallback routing decision.';
146
- }
147
-
148
- export function classifyTask(userMessage, context = '') {
149
- const complexity = detectComplexity(userMessage, context);
150
- const type = detectTaskType(userMessage, context, complexity);
151
- const recommendedTier = recommendTier(type, complexity);
152
-
153
- return {
154
- type,
155
- complexity,
156
- recommendedTier,
157
- reason: buildReason(type, complexity),
158
- };
159
- }
160
-
161
- export function selectModel(classification, config) {
162
- const tier = classification?.recommendedTier ?? 'balanced';
163
- if (config?.[tier]) {
164
- return config[tier];
165
- }
166
-
167
- if (tier === 'powerful' && config?.advisorModel) {
168
- return config.advisorModel;
169
- }
170
-
171
- if (tier === 'balanced' && config?.defaultModel) {
172
- return config.defaultModel;
173
- }
174
-
175
- if (tier === 'fast' && config?.fast) {
176
- return config.fast;
177
- }
178
-
179
- return config?.defaultModel ?? config?.balanced ?? config?.fast ?? config?.powerful ?? null;
180
- }
@@ -1,89 +0,0 @@
1
- const HEDGING_WORDS = [
2
- 'maybe',
3
- 'might',
4
- 'i think',
5
- 'probably',
6
- 'possibly',
7
- 'could be',
8
- 'perhaps',
9
- 'có thể',
10
- 'chắc là',
11
- 'hình như',
12
- ];
13
-
14
- const VERIFICATION_WORDS = [
15
- 'verified',
16
- 'tested',
17
- 'passes',
18
- 'passed',
19
- 'checked',
20
- 'confirmed',
21
- 'validated',
22
- 'đã test',
23
- 'đã kiểm tra',
24
- ];
25
-
26
- function normalize(text) {
27
- return String(text ?? '').toLowerCase();
28
- }
29
-
30
- function buildResult(score, factors) {
31
- const clamped = Math.max(0, Math.min(100, Math.round(score)));
32
- const level = clamped >= 80 ? 'high' : clamped >= 50 ? 'medium' : 'low';
33
- const action = clamped >= 80 ? 'respond' : clamped >= 50 ? 'warn' : 'clarify';
34
-
35
- return {
36
- score: clamped,
37
- level,
38
- factors,
39
- action,
40
- };
41
- }
42
-
43
- export function scoreConfidence(output, taskType, context = '') {
44
- const normalizedOutput = normalize(output);
45
- const normalizedContext = normalize(context);
46
- const factors = [];
47
- let score = 50;
48
-
49
- if (normalizedContext.trim().length >= 24) {
50
- score += 15;
51
- factors.push({ name: 'context_availability', impact: 'positive', reason: 'Enough task context is available.' });
52
- } else {
53
- score -= 20;
54
- factors.push({ name: 'context_availability', impact: 'negative', reason: 'Very little context is available.' });
55
- }
56
-
57
- if (normalizedOutput.length >= 60 && !HEDGING_WORDS.some((word) => normalizedOutput.includes(word))) {
58
- score += 15;
59
- factors.push({ name: 'task_clarity', impact: 'positive', reason: 'Output is specific and direct.' });
60
- } else if (normalizedOutput.length < 30) {
61
- score -= 10;
62
- factors.push({ name: 'task_clarity', impact: 'negative', reason: 'Output is too short to inspire confidence.' });
63
- }
64
-
65
- const hedges = HEDGING_WORDS.filter((word) => normalizedOutput.includes(word));
66
- if (hedges.length > 0) {
67
- score -= 30;
68
- factors.push({ name: 'model_certainty', impact: 'negative', reason: `Hedging language detected: ${hedges.join(', ')}.` });
69
- } else {
70
- score += 10;
71
- factors.push({ name: 'model_certainty', impact: 'positive', reason: 'Language is direct rather than hedged.' });
72
- }
73
-
74
- const verificationHits = VERIFICATION_WORDS.filter((word) => normalizedOutput.includes(word));
75
- if (verificationHits.length > 0) {
76
- score += 20;
77
- factors.push({ name: 'verification', impact: 'positive', reason: `Verification signals found: ${verificationHits.join(', ')}.` });
78
- } else if (taskType === 'coding' || taskType === 'debug' || taskType === 'debug_hard') {
79
- score -= 10;
80
- factors.push({ name: 'verification', impact: 'negative', reason: 'No verification evidence was provided for a technical task.' });
81
- }
82
-
83
- if (/\b(previous|existing|again|recall|memory)\b/.test(normalizedContext)) {
84
- score += 5;
85
- factors.push({ name: 'prior_knowledge', impact: 'positive', reason: 'Context suggests continuity with prior knowledge.' });
86
- }
87
-
88
- return buildResult(score, factors);
89
- }
@@ -1,165 +0,0 @@
1
- const REQUIREMENT_PREFIX = /^\s*(?:[-*•]|\d+[.)])\s+/;
2
- const STOPWORDS = new Set([
3
- 'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
4
- 'be', 'this', 'that', 'it', 'as', 'by', 'run', 'add', 'use', 'please', 'giúp', 'hãy',
5
- 'và', 'là', 'cho', 'cần', 'một', 'những',
6
- ]);
7
-
8
- function normalize(text) {
9
- return String(text ?? '')
10
- .toLowerCase()
11
- .replace(/```[\w-]*\n?/g, ' ')
12
- .replace(/```/g, ' ')
13
- .replace(/[^\p{L}\p{N}\s]/gu, ' ')
14
- .replace(/\s+/g, ' ')
15
- .trim();
16
- }
17
-
18
- function unwrapCodeFence(output) {
19
- const match = String(output ?? '').match(/```(?:[\w-]+)?\n([\s\S]*?)```/);
20
- return match ? match[1].trim() : String(output ?? '').trim();
21
- }
22
-
23
- function tokenize(text) {
24
- return normalize(text)
25
- .split(/\s+/)
26
- .filter((token) => token && !STOPWORDS.has(token) && token.length > 1);
27
- }
28
-
29
- function parseRequirements(spec) {
30
- if (!spec) {
31
- return [];
32
- }
33
-
34
- if (Array.isArray(spec?.requirements)) {
35
- return spec.requirements.filter(Boolean);
36
- }
37
-
38
- if (typeof spec === 'string') {
39
- const lines = spec.split('\n').map((line) => line.trim()).filter(Boolean);
40
- const bulletLines = lines.filter((line) => REQUIREMENT_PREFIX.test(line));
41
- if (bulletLines.length > 0) {
42
- return bulletLines.map((line) => line.replace(REQUIREMENT_PREFIX, '').trim());
43
- }
44
-
45
- return lines.length > 0 ? [lines.join(' ')] : [];
46
- }
47
-
48
- return [];
49
- }
50
-
51
- function requirementCovered(requirement, output) {
52
- const outputTokens = new Set(tokenize(output));
53
- const requirementTokens = tokenize(requirement);
54
- if (requirementTokens.length === 0) {
55
- return true;
56
- }
57
-
58
- const matches = requirementTokens.filter((token) => outputTokens.has(token)).length;
59
- const threshold = requirementTokens.length <= 2 ? requirementTokens.length : Math.max(2, Math.ceil(requirementTokens.length * 0.6));
60
- return matches >= threshold;
61
- }
62
-
63
- function buildCompletenessCheck(output, spec) {
64
- const requirements = parseRequirements(spec);
65
- if (requirements.length === 0) {
66
- return { name: 'completeness', passed: true };
67
- }
68
-
69
- const missing = requirements.filter((requirement) => !requirementCovered(requirement, output));
70
- return {
71
- name: 'completeness',
72
- passed: missing.length === 0,
73
- details: missing.length === 0 ? undefined : `Missing requirement coverage: ${missing.join('; ')}`,
74
- };
75
- }
76
-
77
- function countListItems(output) {
78
- return String(output ?? '')
79
- .split('\n')
80
- .filter((line) => /^\s*(?:[-*•]|\d+[.)])\s+/.test(line))
81
- .length;
82
- }
83
-
84
- function buildConsistencyCheck(output) {
85
- const text = String(output ?? '');
86
- const numericClaim = text.match(/\b(\d+)\s+(?:steps?|items?|checks?|requirements?|bước|mục)\b/i);
87
- if (!numericClaim) {
88
- return { name: 'consistency', passed: true };
89
- }
90
-
91
- const expectedCount = Number.parseInt(numericClaim[1], 10);
92
- const actualCount = countListItems(text);
93
- const passed = actualCount === expectedCount;
94
-
95
- return {
96
- name: 'consistency',
97
- passed,
98
- details: passed ? undefined : `Declared ${expectedCount} items but found ${actualCount}.`,
99
- };
100
- }
101
-
102
- function detectRequestedFormat(taskType, spec) {
103
- if (typeof spec === 'object' && typeof spec?.format === 'string') {
104
- return spec.format.toLowerCase();
105
- }
106
-
107
- const combined = `${taskType ?? ''}\n${typeof spec === 'string' ? spec : ''}`.toLowerCase();
108
- if (combined.includes('json')) return 'json';
109
- if (combined.includes('code')) return 'code';
110
- if (combined.includes('list')) return 'list';
111
- return 'text';
112
- }
113
-
114
- function buildFormatCheck(output, taskType, spec) {
115
- const format = detectRequestedFormat(taskType, spec);
116
- const raw = String(output ?? '').trim();
117
-
118
- if (format === 'json') {
119
- try {
120
- JSON.parse(unwrapCodeFence(raw));
121
- return { name: 'format', passed: true };
122
- } catch (error) {
123
- return { name: 'format', passed: false, details: `Invalid JSON output: ${error.message}` };
124
- }
125
- }
126
-
127
- if (format === 'code') {
128
- const passed = /```/.test(raw);
129
- return {
130
- name: 'format',
131
- passed,
132
- details: passed ? undefined : 'Expected a code block in the output.',
133
- };
134
- }
135
-
136
- if (format === 'list') {
137
- const passed = countListItems(raw) > 0;
138
- return {
139
- name: 'format',
140
- passed,
141
- details: passed ? undefined : 'Expected a list-style response.',
142
- };
143
- }
144
-
145
- return { name: 'format', passed: true };
146
- }
147
-
148
- export function validateOutput(output, taskType, spec) {
149
- const checks = [
150
- buildCompletenessCheck(output, spec),
151
- buildConsistencyCheck(output),
152
- buildFormatCheck(output, taskType, spec),
153
- ];
154
-
155
- const failedChecks = checks.filter((check) => !check.passed);
156
-
157
- return {
158
- passed: failedChecks.length === 0,
159
- checks,
160
- shouldRetry: failedChecks.length > 0,
161
- retryHint: failedChecks.length === 0
162
- ? undefined
163
- : `Previous output failed validation: ${failedChecks.map((check) => `${check.name}${check.details ? ` (${check.details})` : ''}`).join('; ')}`,
164
- };
165
- }
@@ -1,230 +0,0 @@
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
- // Tri-state on purpose: true | false | null(unknown). Returning false on a detection
86
- // FAILURE would be read as "unicMode is off", which GRANTS the fallback-model exception
87
- // below — i.e. deleting or breaking one gitignored file would make the gate accept a
88
- // plain code-tier receipt. Unknown must deny the exception, not grant it.
89
- async function detectUnicModeSafe() {
90
- try {
91
- const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
92
- if (!fs.existsSync(gatewayPath)) return null;
93
- const mod = await import(pathToFileURL(gatewayPath).href);
94
- if (typeof mod.detectUnicGateway !== 'function') return null;
95
- const result = mod.detectUnicGateway({ rootDir: projectRoot });
96
- return !!result?.unicMode;
97
- } catch {
98
- return null;
99
- }
100
- }
101
-
102
- // Anchored, not a substring match: "unic-vision-mini" and "not-unic-vision" are different
103
- // models and must not inherit the real one's capability by sharing a substring.
104
- // Only reached when unicMode is true or unknown — off-gateway sessions already exited
105
- // above — so `unic-vision` is the only model that can legitimately clear the gate here.
106
- function isVisionCapable(model) {
107
- if (typeof model !== 'string' || !model.trim()) return false;
108
- return /^unic-vision$/i.test(model.trim());
109
- }
110
-
111
- const VALID_STATUSES = new Set(['OK', 'NO_IMAGE', 'UNREADABLE']);
112
-
113
- // A receipt whose self-reported model is not vision-capable (unic-code, unic-smart,
114
- // sonnet, opus, ...) is treated as ABSENT and still blocks — accepting it would make the
115
- // whole enforcement design theatre. A JSON.parse throw is caught per-receipt and turned
116
- // into BLOCK, never an accidental skip-through.
117
- // The gate keys its directory on the hook payload's session_id, but the analyst resolves
118
- // its own sessionId by discovering the newest transcript. Those agree in the common case
119
- // and diverge with concurrent sessions on one repo, which would leave the receipt in a
120
- // sibling directory and hold the gate shut. The sha is content-addressed, so accepting a
121
- // receipt from any session dir is safe: it still must exist and be vision-capable.
122
- function findReceipt(sha) {
123
- const own = path.join(sessionDir, `analyzed-${sha}.json`);
124
- if (fs.existsSync(own)) return own;
125
- const visionRoot = path.join(projectRoot, '.ukit', 'storage', 'cache', 'vision');
126
- let entries = [];
127
- try {
128
- entries = fs.readdirSync(visionRoot, { withFileTypes: true });
129
- } catch {
130
- return null;
131
- }
132
- for (const entry of entries) {
133
- if (!entry.isDirectory() || entry.name === sessionId) continue;
134
- const candidate = path.join(visionRoot, entry.name, `analyzed-${sha}.json`);
135
- if (fs.existsSync(candidate)) return candidate;
136
- }
137
- return null;
138
- }
139
-
140
- function checkReceipt(receiptPath) {
141
- if (!receiptPath || !fs.existsSync(receiptPath)) {
142
- return { ok: false, reason: 'no analysis receipt found' };
143
- }
144
- const receipt = readJsonSafe(receiptPath);
145
- if (!receipt) {
146
- return { ok: false, reason: 'receipt is not valid JSON' };
147
- }
148
- if (!isVisionCapable(receipt.model)) {
149
- const modelLabel = typeof receipt.model === 'string' && receipt.model.trim() ? receipt.model.trim() : '(missing)';
150
- return { ok: false, reason: `receipt model "${modelLabel}" is not vision-capable — treated as absent` };
151
- }
152
- const status = receipt.status;
153
- if (!VALID_STATUSES.has(status)) {
154
- return { ok: false, reason: `receipt status "${status}" is not a valid terminal outcome` };
155
- }
156
- return { ok: true };
157
- }
158
-
159
- (async () => {
160
- const now = Date.now();
161
- const unicMode = await detectUnicModeSafe();
162
-
163
- // The gate exists because the UNIC gateway BLINDS the model: unic-code/unic-smart
164
- // cannot see images there, so an unanalysed image means guessing. Off the gateway
165
- // Claude reads images natively and there is nothing left to enforce.
166
- //
167
- // This early exit is what makes the gate satisfiable at all off-gateway. The previous
168
- // design tried to cover this case by accepting a receipt whose model string equalled
169
- // modelTiers.vision.fallbackModel — but the analyst's frontmatter pins `unic-vision`,
170
- // so off-gateway it runs on whatever model is actually available and self-reports THAT.
171
- // A hardcoded fallback ID could never match it, so no valid receipt was reachable and
172
- // one pasted screenshot hard-blocked Edit/Write for the rest of the session.
173
- //
174
- // Only a POSITIVE false stands the gate down. null (detection failed) still enforces:
175
- // breaking one gitignored file must not be a bypass.
176
- if (unicMode === false) {
177
- process.exit(0);
178
- }
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);
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 $?