@ngockhoale/ukit 2.2.14 → 2.2.16
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 +17 -0
- package/manifests/platform.full.yaml +0 -13
- package/package.json +1 -1
- package/templates/.claude/hooks/context-hardcap-gate.sh +2 -2
- package/templates/.claude/hooks/vision-router.sh +8 -14
- package/templates/.claude/settings.json +0 -5
- package/templates/.claude/ukit/index/provision-worktree.mjs +1 -1
- package/templates/.claude/ukit/index/unic-gateway.mjs +1 -1
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +0 -1
- package/templates/.omp/README.md +3 -3
- package/templates/.omp/hooks/pre/ukit-bridge.js +0 -2
- package/templates/CLAUDE.md +1 -1
- package/templates/.claude/hooks/vision-gate.sh +0 -230
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.2.16 - 2026-09-06
|
|
6
|
+
|
|
7
|
+
The vision gate is gone — image work is advisory-routed, never blocked. Since TASK-008, a
|
|
8
|
+
pasted image on the UNIC gateway armed `vision-gate.sh`, which refused every Edit/Write until
|
|
9
|
+
`ukit-vision-analyst` had produced receipts for ALL pending images. In practice this
|
|
10
|
+
repeatedly stranded sessions (receipts that never landed, one stale marker blocking
|
|
11
|
+
unrelated writes), so the maintainer retired the hard block: `vision-gate.sh` and its
|
|
12
|
+
manifest entry `hook-vision-gate` are removed from both the live tree and templates, the omp
|
|
13
|
+
bridge and `hook-chain-runner.mjs` no longer list it, and `vision-router.sh` now says "never
|
|
14
|
+
guess at image contents" instead of claiming edits are GATED. Routing to
|
|
15
|
+
`ukit-vision-analyst` still happens — on this gateway the main tier genuinely cannot read
|
|
16
|
+
images — but nothing blocks while it runs. Supersedes the 2.2.15 escape hatch, which patched
|
|
17
|
+
a message on a gate that no longer exists. Verified: vision-hook, route-vision,
|
|
18
|
+
vision-agent, install-wiring, provision-worktree, and ompHookBridge (52/52) all green.
|
|
19
|
+
(An interim 2026-09-05 patch that added an escape hatch to the block message is included
|
|
20
|
+
here and immediately superseded — the gate itself no longer exists.)
|
|
21
|
+
|
|
5
22
|
## 2.2.14 - 2026-09-05
|
|
6
23
|
|
|
7
24
|
The context hard-cap gate no longer bricks healthy sessions with a phantom token count. The
|
|
@@ -1131,19 +1131,6 @@ items:
|
|
|
1131
1131
|
packs:
|
|
1132
1132
|
- core
|
|
1133
1133
|
|
|
1134
|
-
- id: hook-vision-gate
|
|
1135
|
-
type: hook
|
|
1136
|
-
sourceTemplate: .claude/hooks/vision-gate.sh
|
|
1137
|
-
targetPath: .claude/hooks/vision-gate.sh
|
|
1138
|
-
requires:
|
|
1139
|
-
- ukit-index-extract-image-script
|
|
1140
|
-
- ukit-index-unic-gateway-script
|
|
1141
|
-
mergeStrategy: overwrite_with_backup
|
|
1142
|
-
variables: []
|
|
1143
|
-
enabledByDefault: true
|
|
1144
|
-
packs:
|
|
1145
|
-
- core
|
|
1146
|
-
|
|
1147
1134
|
- id: hook-context-hardcap-gate
|
|
1148
1135
|
type: hook
|
|
1149
1136
|
sourceTemplate: .claude/hooks/context-hardcap-gate.sh
|
package/package.json
CHANGED
|
@@ -177,8 +177,8 @@ function readRunCursor() {
|
|
|
177
177
|
process.stderr.write(`${lines.join('\n')}\n`);
|
|
178
178
|
process.exit(2);
|
|
179
179
|
})().catch((err) => {
|
|
180
|
-
//
|
|
181
|
-
//
|
|
180
|
+
// A logic error here fails OPEN: this is a backstop on top of advisory nudges,
|
|
181
|
+
// not a correctness gate — a broken gate must not brick every session.
|
|
182
182
|
process.stderr.write(`context-hardcap-gate: internal error, failing open: ${err?.message ?? err}\n`);
|
|
183
183
|
process.exit(0);
|
|
184
184
|
});
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# UserPromptSubmit hook: detect image input (pasted / local path / URL) and
|
|
3
|
-
#
|
|
2
|
+
# UserPromptSubmit hook: detect image input (pasted / local path / URL) and mark it for
|
|
3
|
+
# the vision analyst by writing pending-<sha>.json markers via extract-image.mjs.
|
|
4
4
|
#
|
|
5
5
|
# FAILS OPEN, unconditionally: this hook must never block the user's prompt. Any
|
|
6
6
|
# failure (malformed stdin, missing extractor, node crash) degrades to "no hint,
|
|
7
|
-
# no markers" and exit 0.
|
|
8
|
-
# which fails closed on Edit/Write.
|
|
7
|
+
# no markers" and exit 0. Advisory only — nothing is ever blocked.
|
|
9
8
|
#
|
|
10
9
|
# Detection only — never decodes an image, never writes an image file. Marker
|
|
11
10
|
# writing is delegated entirely to `extract-image.mjs --mark-pending`; this hook
|
|
@@ -44,7 +43,7 @@ const { pathToFileURL } = require('url');
|
|
|
44
43
|
|
|
45
44
|
const promptText = extractPromptText(payload);
|
|
46
45
|
|
|
47
|
-
// Tri-state
|
|
46
|
+
// Tri-state: true | false | null(unknown). Only a positive false short-circuits.
|
|
48
47
|
async function detectUnicModeSafe() {
|
|
49
48
|
try {
|
|
50
49
|
const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
|
|
@@ -118,14 +117,12 @@ const { pathToFileURL } = require('url');
|
|
|
118
117
|
}
|
|
119
118
|
}
|
|
120
119
|
|
|
121
|
-
// `cases` reflects what the PROMPT mentioned
|
|
122
|
-
//
|
|
123
|
-
// — still worth a hint, but the gate is not armed and must not be claimed to be.
|
|
120
|
+
// `cases` reflects what the PROMPT mentioned. Named paths that do not resolve from
|
|
121
|
+
// the project root still get the unresolved-path note below.
|
|
124
122
|
const cases = [];
|
|
125
123
|
if (markedPasted > 0) cases.push('pasted image');
|
|
126
124
|
if (localMatches.length > 0) cases.push('local file path');
|
|
127
125
|
if (urlMatches.length > 0) cases.push('image URL');
|
|
128
|
-
const armed = markedPasted + markedPath + markedUrl;
|
|
129
126
|
|
|
130
127
|
if (cases.length === 0) {
|
|
131
128
|
process.exit(0);
|
|
@@ -135,8 +132,7 @@ const { pathToFileURL } = require('url');
|
|
|
135
132
|
// Only unicMode true or null reach here; off-gateway already exited. Both remaining
|
|
136
133
|
// cases get the same strict `unic-vision` remedy, so there is no fallback-model branch
|
|
137
134
|
// to advertise. There must never be one: the analyst self-reports the model it really
|
|
138
|
-
// ran on, so telling it to claim some other ID is asking it to falsify the receipt
|
|
139
|
-
// which the gate is entitled to reject, and which defeats the point of having a gate.
|
|
135
|
+
// ran on, so telling it to claim some other ID is asking it to falsify the receipt.
|
|
140
136
|
let unicNote = '';
|
|
141
137
|
if (unicMode === true) {
|
|
142
138
|
try {
|
|
@@ -151,9 +147,7 @@ const { pathToFileURL } = require('url');
|
|
|
151
147
|
}
|
|
152
148
|
}
|
|
153
149
|
|
|
154
|
-
const reasonLine =
|
|
155
|
-
? '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:'
|
|
156
|
-
: 'unic-code / unic-smart cannot read images on this gateway. Do this before anything else:';
|
|
150
|
+
const reasonLine = 'unic-code / unic-smart cannot read images on this gateway — never guess at\nimage contents. Do this before relying on them:';
|
|
157
151
|
const agentLine = ' 2. Agent(subagent_type: "ukit-vision-analyst") [model: unic-vision]';
|
|
158
152
|
|
|
159
153
|
const lines = [
|
|
@@ -84,11 +84,6 @@
|
|
|
84
84
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\"",
|
|
85
85
|
"timeout": 8
|
|
86
86
|
},
|
|
87
|
-
{
|
|
88
|
-
"type": "command",
|
|
89
|
-
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-gate.sh\"",
|
|
90
|
-
"timeout": 8
|
|
91
|
-
},
|
|
92
87
|
{
|
|
93
88
|
"type": "command",
|
|
94
89
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\"",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - .claude/ -> REAL COPY preserving file modes (never a symlink: a worktree
|
|
8
8
|
* edit must not write through into the live main-tree mirror).
|
|
9
9
|
* Mode preservation matters: every .claude/hooks/*.sh is 755 and
|
|
10
|
-
* tests/handoff/
|
|
10
|
+
* tests/handoff/cycle7/provision-worktree.test.mjs asserts the exec bit.
|
|
11
11
|
* - .ukit/storage/config.json -> copy
|
|
12
12
|
* - .cache/index/ -> copy
|
|
13
13
|
*
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* Every probe here is scoped to what changes the CALLING
|
|
23
23
|
* runtime's OWN outbound endpoint — same rule that already governs probes 1-3. Cross-tool
|
|
24
24
|
* configs are NOT probed. This module is only ever invoked from Claude Code / omp hooks
|
|
25
|
-
* (vision-router.sh,
|
|
25
|
+
* (vision-router.sh, route-task.mjs, and the omp bridge) to gate what THIS
|
|
26
26
|
* session does — a Codex `config.toml` `base_url`, a Kilo `secrets.json` endpoint, or an
|
|
27
27
|
* `OPENAI_BASE_URL` env var describe a completely different tool's outbound endpoint and say
|
|
28
28
|
* nothing about where Claude Code or omp itself is sending requests. Treating them as evidence
|
package/templates/.omp/README.md
CHANGED
|
@@ -62,14 +62,14 @@ One source of truth, two runtimes — fix a hook once and both runtimes get the
|
|
|
62
62
|
Two things worth knowing:
|
|
63
63
|
|
|
64
64
|
**Tool names are mapped explicitly, never guessed.** omp's write surface is `edit`, `write` *and*
|
|
65
|
-
`ast_edit`; all three map to the `Edit` group, or `ast_edit` would slip past `protect-files.sh
|
|
66
|
-
`
|
|
65
|
+
`ast_edit`; all three map to the `Edit` group, or `ast_edit` would slip past `protect-files.sh`.
|
|
66
|
+
`eval` maps to `Bash` so it still hits `block-dangerous.sh`. Anything not in the
|
|
67
67
|
table maps to nothing and runs zero scripts — it never falls back to `Bash` or `Edit`.
|
|
68
68
|
|
|
69
69
|
**Failure direction is per-script, transcribed from each script's own header — not a blanket rule.**
|
|
70
70
|
|
|
71
71
|
- *Fail closed* (a crash or non-zero exit blocks the action): `protect-files.sh`,
|
|
72
|
-
`stale-spec-guard.sh`, `handoff-model-guard.sh`, `
|
|
72
|
+
`stale-spec-guard.sh`, `handoff-model-guard.sh`, `context-hardcap-gate.sh`,
|
|
73
73
|
`block-dangerous.sh`, `verification-guard.sh`. These are gates; a broken gate must not open.
|
|
74
74
|
- *Fail open* (a crash logs a warning and the action proceeds): the advisory scripts —
|
|
75
75
|
routing, backups, output compression, context reinjection, pressure reset, handoff resume.
|
|
@@ -27,7 +27,6 @@ export const HOOK_EVENT_MAP = {
|
|
|
27
27
|
'pre-edit-backup.sh',
|
|
28
28
|
'skill-router.sh',
|
|
29
29
|
'handoff-model-guard.sh',
|
|
30
|
-
'vision-gate.sh',
|
|
31
30
|
'context-hardcap-gate.sh',
|
|
32
31
|
],
|
|
33
32
|
Bash: [
|
|
@@ -83,7 +82,6 @@ export const FAIL_CLOSED_SCRIPTS = new Set([
|
|
|
83
82
|
'protect-files.sh',
|
|
84
83
|
'stale-spec-guard.sh',
|
|
85
84
|
'handoff-model-guard.sh',
|
|
86
|
-
'vision-gate.sh',
|
|
87
85
|
'context-hardcap-gate.sh',
|
|
88
86
|
'block-dangerous.sh',
|
|
89
87
|
]);
|
package/templates/CLAUDE.md
CHANGED
|
@@ -209,7 +209,7 @@ This is internal orchestration — end users do not need to know about tiers, th
|
|
|
209
209
|
`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.
|
|
210
210
|
|
|
211
211
|
- **Gateway detection**: UNIC routing for a Claude Code session is decided ONLY by what changes Claude Code's own outbound endpoint — `ANTHROPIC_BASE_URL` (env var, or the `env.ANTHROPIC_BASE_URL` key in project/home `.claude/settings.json`) containing `unicjsc.com`. Other tools' configs — Codex `config.toml`, Kilo `secrets.json`, or an `OPENAI_BASE_URL` env var — describe a different tool's endpoint entirely and never decide this session's routing.
|
|
212
|
-
- **
|
|
212
|
+
- **Advisory routing (no hard block)**: when an image reaches the prompt, the vision router reminds the session to have `ukit-vision-analyst` analyse it before relying on its contents. Edits are **never blocked** — correctness relies on the model routing images to the analyst instead of guessing.
|
|
213
213
|
- This is internal orchestration — end users never invoke a vision command directly; `ukit install` plus natural language remains the whole surface. No new commands.
|
|
214
214
|
|
|
215
215
|
## Skills
|
|
@@ -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 $?
|