@ngockhoale/ukit 2.3.10 → 2.3.12

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,86 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.3.12 - 2026-09-11
6
+
7
+ Vision-lane freeze-sweep (post-2.3.11): the four defects that stranded the vision image lane on
8
+ nearly every image are fixed, the second consumer of the same advisory now honors the fallback,
9
+ and the installed vision-agent file mode is corrected. The cycle10 + cycle4 scripts prove the
10
+ plumbing; a real UNIC round-trip dispatch is not covered by the suite (see `docs/STATUS.md`).
11
+
12
+ **P1 — `--detect` could not resolve absolute image paths outside the project, or paths wrapped
13
+ in quotes or trailing punctuation.** `extract-image.mjs`'s `--detect` arm accepted relative
14
+ paths and in-project absolutes, but the path-capture regex in `vision-router.sh` then refused an
15
+ out-of-project image ("did not resolve from the project root … Nothing was armed"), and
16
+ trailing punctuation / wrapping quotes (`'<abs>/x.png'.`, `"<abs>/x.png"`) broke the existence
17
+ check. Fix: `stripPathNoise()` in the extractor tries raw / soft-trailing-stripped /
18
+ wrapping-pair-stripped / both shapes against `fs.existsSync`; the hook-side regex now uses the
19
+ same tolerance. The interior of the path is never touched, and relative handling is unchanged.
20
+
21
+ **P1 — marker receipts were leaked into `images[]`, so the dispatch hint could carry a
22
+ `pending-*.json` path to the analyst.** `--mark-pending --json` returned each marker receipt
23
+ (`{ path: 'pending-<sha>.json', bytes: 0 }`) inside the same `images[]` array that was supposed
24
+ to carry materialized images; downstream code that filtered "bytes > 0" still sometimes passed
25
+ the marker through, and the dispatch hint could carry a `pending-*.json` path the analyst would
26
+ not have materialized. Fix: `images[]` is now the materialized-only invariant (empty in
27
+ `--detect` / `--mark-pending` mode; every entry has bytes > 0 and a non-`.json` extension
28
+ otherwise); receipts live in a new top-level `pendingMarkers[]` carrying
29
+ `{ path, sha, bytes: 0, … }` per detection. On-disk marker format (`pending-<sha>.json`,
30
+ `bytes: 0`) is untouched — only JSON presentation changed.
31
+
32
+ **P1 — the hook never forwarded the parent transcript, and default session discovery could
33
+ pick a newer-but-empty subagent transcript over the parent's image-bearing one.** When a
34
+ subagent spawn raced the paste, the subagent's empty transcript was the newest file in the
35
+ sessions root, so `discoverSessionFile()` returned it and the analyst got `imageCount: 0`. The
36
+ dispatch hint also lacked any `--session` argument, so the analyst could not materialize from
37
+ the parent transcript even when one was named in the payload. Fix: `discoverSessionFile()`
38
+ honors `CLAUDE_CONFIG_DIR` when set, calls `fs.realpathSync` so the macOS `/var` ↔ `/private/var`
39
+ symlinks do not drift the slug, and runs a bounded newest-first scan (`DISCOVERY_SCAN_BOUND = 5`)
40
+ with a cheap 64 KiB `transcriptHasImageHead()` peek — never an unbounded walk or full-content
41
+ parse. When nothing image-bearing is found, the script emits an explicit `STATUS: NO_IMAGE
42
+ scanned=<names>` (and a JSON line first under `--json`) instead of a fake success. The hook
43
+ hint now always carries `--session <payload.transcript_path>` whenever the payload provides one;
44
+ explicit `--session` still bypasses discovery and the NO_IMAGE branch entirely.
45
+
46
+ **P1 — when the vision lane was unavailable, both consumers forced a failing dispatch instead
47
+ of degrading to native read.** `unic-gateway.mjs`'s `aliasAvailable: false` advisory was
48
+ ignored by `resolveVisionModel` in `route-task.mjs` and by the hook's hint builder, so a
49
+ gateway without vision routed the analyst task with `[model: unic-vision]` and the agent model
50
+ itself had to read the image, only to fail mid-dispatch. Fix: both consumers now honor
51
+ `aliasAvailable === false` — `resolveVisionModel` returns a non-`unic-vision` (native/main-
52
+ model) lane and states the unavailability in the routed output; the hook hint omits
53
+ `[model: unic-vision]`, says `vision lane unavailable`, and instructs a native read instead of
54
+ dispatching the analyst. The missing-field case preserves today's default; the
55
+ `unicMode === false` short-circuit is unchanged. Also: installed
56
+ `.claude/agents/ukit-vision-analyst.md` is now mode 0644 (template stays `100644` in git) — a
57
+ stale 755 from a prior install that was never regenerated by `ukit install` because
58
+ `copyFileSafe` preserves template mode.
59
+
60
+ ## 2.3.11 - 2026-09-11
61
+
62
+ Freeze-sweep wave 11 (post-2.3.10): the two deferred liveness candidates fixed via TDD, plus
63
+ a self-refresh downgrade trap repaired during verification. All template/live mirrors are
64
+ byte-synced.
65
+
66
+ **P1 — SessionStart hook could hang the session forever.** `handoff-resume.sh`'s "advisory,
67
+ always exit 0" rested on `|| true`, which never fires when the embedded node process wedges
68
+ (hung runtime import, unresolved lock wait, stalled file read) — session start stalls mid-run
69
+ with no visible reason. A 3s wall-clock watchdog (`UKIT_HOOK_DEADLINE_MS`, env-tunable) now
70
+ exits 0 unconditionally, and the RUN.md read is async so sync I/O cannot block the timer.
71
+
72
+ **P1 — router state writes could tear; route-audit merge lost entries.** All five
73
+ `skill-router-state.json` / `route-audit.json` writes were direct `writeFileSync`: a torn or
74
+ empty state parses as `{}` downstream and completion-gate.sh then releases a premature Stop
75
+ with no message. Writes now go through tmp+rename (`writeJsonFileAtomic`), and the audit
76
+ append runs under the standard PID + owner-token lock (5s bounded, fail-open). 16 concurrent
77
+ router runs previously kept only 13/16 audit entries; now 16/16.
78
+
79
+ **Incident — post-release self-refresh downgraded live hooks.** Running `ukit install` from a
80
+ global CLI older than the published release rewrote six live hook mirrors with pre-release
81
+ content, resurrecting just-fixed lock-theft bugs in the development workspace. Recovered by
82
+ upgrading the global CLI and re-installing; the deferred guard candidate is a stale-CLI
83
+ warning in `ukit install`.
84
+
5
85
  ## 2.3.10 - 2026-09-11
6
86
 
7
87
  Freeze-sweep waves 7-10 (post-2.3.9): twelve verified mid-run-freeze defects fixed, each locked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.3.10",
3
+ "version": "2.3.12",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,6 +27,14 @@ const fs = require('fs');
27
27
  const path = require('path');
28
28
  const { pathToFileURL } = require('url');
29
29
 
30
+ // Wall-clock watchdog. This hook is ADVISORY ONLY, so any hang — a wedged runtime
31
+ // import, a lock wait that never resolves, a stalled filesystem read — must end in a
32
+ // clean exit 0 instead of stalling session start mid-run with no visible reason.
33
+ // Every read below is async on purpose: sync I/O would block the loop and this timer
34
+ // could never fire. Keep it that way.
35
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
36
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
37
+
30
38
  const payload = (() => {
31
39
  try {
32
40
  const parsed = JSON.parse(process.env.INPUT || '');
@@ -68,7 +76,8 @@ async function emitOrdinaryResume() {
68
76
  (async () => {
69
77
  let text;
70
78
  try {
71
- text = fs.readFileSync(runPath, 'utf8');
79
+ // Async on purpose — a sync read on a stalled mount would block the watchdog timer.
80
+ text = await fs.promises.readFile(runPath, 'utf8');
72
81
  } catch {
73
82
  // No handoff cursor: an ordinary routed-task intent may still be resumable.
74
83
  await emitOrdinaryResume();
@@ -121,6 +121,23 @@ const { pathToFileURL } = require('url');
121
121
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
122
122
  }
123
123
 
124
+ function writeJsonFileAtomic(filePath, text) {
125
+ // tmp+rename: readers see old-or-new, never a torn/half file. A truncated
126
+ // skill-router-state.json parses as `{}` downstream and completion-gate.sh then
127
+ // releases a premature Stop with no message — the mid-run silent-stop class.
128
+ ensureDir(filePath);
129
+ const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
130
+ try {
131
+ fs.writeFileSync(tempPath, text);
132
+ fs.renameSync(tempPath, filePath);
133
+ } catch (error) {
134
+ try {
135
+ fs.rmSync(tempPath, { force: true });
136
+ } catch {}
137
+ throw error;
138
+ }
139
+ }
140
+
124
141
  function defaultRuntimeConfig() {
125
142
  return {
126
143
  memory: {
@@ -2286,45 +2303,124 @@ const { pathToFileURL } = require('url');
2286
2303
  };
2287
2304
  }
2288
2305
 
2289
- function appendRouteAuditEntry(filePath, entry) {
2290
- if (!entry || typeof entry !== 'object') {
2291
- return;
2306
+ // Owner-token lock protocol, protocol-compatible with runtime token-utils withFileLock
2307
+ // and the advisory permission hooks (mkdir-based `<file>.lock` + owner liveness).
2308
+ // route-audit.json is a read-modify-write merge: without the lock, 16 concurrent
2309
+ // router invocations were verified to keep only 13/16 entries (lost updates).
2310
+ function isPidAlive(pid) {
2311
+ if (!Number.isInteger(pid) || pid <= 0) return false;
2312
+ try {
2313
+ process.kill(pid, 0);
2314
+ return true;
2315
+ } catch (error) {
2316
+ return error?.code === 'EPERM';
2292
2317
  }
2318
+ }
2293
2319
 
2294
- let parsed = { entries: [] };
2295
- if (fs.existsSync(filePath)) {
2320
+ function readLockOwner(lockPath) {
2321
+ const owner = readJson(path.join(lockPath, 'owner'), null);
2322
+ return owner && Number.isInteger(owner.pid) && typeof owner.token === 'string'
2323
+ ? owner
2324
+ : null;
2325
+ }
2326
+
2327
+ async function withStateLock(lockPath, fn) {
2328
+ // Advisory bookkeeping: liveness over strictness — if the lock cannot be acquired
2329
+ // within maxWaitMs the merge runs unlocked (pre-lock behaviour) rather than hanging
2330
+ // or dropping the entry.
2331
+ const staleMs = 10000;
2332
+ const maxWaitMs = 5000;
2333
+ const startedAt = Date.now();
2334
+ const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
2335
+ let held = false;
2336
+ while (!held) {
2296
2337
  try {
2297
- parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
2298
- } catch {
2299
- parsed = { entries: [] };
2338
+ fs.mkdirSync(lockPath);
2339
+ try {
2340
+ fs.writeFileSync(path.join(lockPath, 'owner'), JSON.stringify({
2341
+ pid: process.pid,
2342
+ token: ownerToken,
2343
+ ts: Date.now(),
2344
+ }));
2345
+ } catch (error) {
2346
+ try {
2347
+ fs.rmSync(lockPath, { recursive: true, force: true });
2348
+ } catch {}
2349
+ throw error;
2350
+ }
2351
+ held = true;
2352
+ } catch (error) {
2353
+ if (!error || error.code !== 'EEXIST') return fn();
2354
+ try {
2355
+ const stat = fs.statSync(lockPath);
2356
+ if (Date.now() - stat.mtimeMs > staleMs) {
2357
+ const owner = readLockOwner(lockPath);
2358
+ if (!owner || !isPidAlive(owner.pid)) {
2359
+ try {
2360
+ fs.rmSync(lockPath, { recursive: true, force: true });
2361
+ continue;
2362
+ } catch {}
2363
+ }
2364
+ }
2365
+ } catch {}
2366
+ if (Date.now() - startedAt > maxWaitMs) break;
2367
+ await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
2368
+ }
2369
+ }
2370
+ try {
2371
+ return await fn();
2372
+ } finally {
2373
+ if (held) {
2374
+ const owner = readLockOwner(lockPath);
2375
+ if (owner?.token === ownerToken) {
2376
+ try {
2377
+ fs.rmSync(lockPath, { recursive: true, force: true });
2378
+ } catch {}
2379
+ }
2300
2380
  }
2301
2381
  }
2382
+ }
2302
2383
 
2303
- const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
2304
- const dedupeKey = stableMachineDigest({
2305
- requestKey: entry.requestKey,
2306
- executionMode: entry.executionMode,
2307
- nextActionType: entry.nextActionType,
2308
- nextMilestone: entry.nextMilestone,
2309
- repeatCount: entry.repeatCount,
2310
- rescueMode: entry.rescueMode,
2311
- });
2312
- const filtered = entries.filter((item) => {
2313
- const itemKey = stableMachineDigest({
2314
- requestKey: item?.requestKey || null,
2315
- executionMode: item?.executionMode || null,
2316
- nextActionType: item?.nextActionType || null,
2317
- nextMilestone: item?.nextMilestone || null,
2318
- repeatCount: item?.repeatCount || null,
2319
- rescueMode: item?.rescueMode || null,
2384
+ async function appendRouteAuditEntry(filePath, entry) {
2385
+ if (!entry || typeof entry !== 'object') {
2386
+ return;
2387
+ }
2388
+
2389
+ await withStateLock(`${filePath}.lock`, () => {
2390
+ let parsed = { entries: [] };
2391
+ if (fs.existsSync(filePath)) {
2392
+ try {
2393
+ parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
2394
+ } catch {
2395
+ parsed = { entries: [] };
2396
+ }
2397
+ }
2398
+
2399
+ const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
2400
+ const dedupeKey = stableMachineDigest({
2401
+ requestKey: entry.requestKey,
2402
+ executionMode: entry.executionMode,
2403
+ nextActionType: entry.nextActionType,
2404
+ nextMilestone: entry.nextMilestone,
2405
+ repeatCount: entry.repeatCount,
2406
+ rescueMode: entry.rescueMode,
2407
+ });
2408
+ const filtered = entries.filter((item) => {
2409
+ const itemKey = stableMachineDigest({
2410
+ requestKey: item?.requestKey || null,
2411
+ executionMode: item?.executionMode || null,
2412
+ nextActionType: item?.nextActionType || null,
2413
+ nextMilestone: item?.nextMilestone || null,
2414
+ repeatCount: item?.repeatCount || null,
2415
+ rescueMode: item?.rescueMode || null,
2416
+ });
2417
+ return itemKey !== dedupeKey;
2320
2418
  });
2321
- return itemKey !== dedupeKey;
2322
- });
2323
2419
 
2324
- ensureDir(filePath);
2325
- fs.writeFileSync(filePath, JSON.stringify({
2326
- entries: [entry, ...filtered].slice(0, 40),
2327
- }));
2420
+ writeJsonFileAtomic(filePath, JSON.stringify({
2421
+ entries: [entry, ...filtered].slice(0, 40),
2422
+ }));
2423
+ });
2328
2424
  }
2329
2425
 
2330
2426
  function shouldIncludePreviousContext({ routingContext = {}, useIndexedContext = true } = {}) {
@@ -2551,7 +2647,7 @@ const { pathToFileURL } = require('url');
2551
2647
  // route: completion-gate.sh reads routeSummary/requestKey from this state to decide
2552
2648
  // whether a Stop is premature. Downgrading to a route-less state here silently disarms
2553
2649
  // the gate mid-task, so carry the previous route forward until a real route replaces it.
2554
- fs.writeFileSync(statePath, JSON.stringify({
2650
+ writeJsonFileAtomic(statePath, JSON.stringify({
2555
2651
  ...(sessionId ? { sessionId } : {}),
2556
2652
  fingerprint,
2557
2653
  ts: now,
@@ -2672,7 +2768,7 @@ const { pathToFileURL } = require('url');
2672
2768
  };
2673
2769
  provisionalState.fingerprint = buildRouteStateFingerprint(provisionalState);
2674
2770
  ensureDir(statePath);
2675
- fs.writeFileSync(statePath, JSON.stringify(provisionalState));
2771
+ writeJsonFileAtomic(statePath, JSON.stringify(provisionalState));
2676
2772
 
2677
2773
  let cacheUtils = null;
2678
2774
  if (fs.existsSync(cacheUtilsPath)) {
@@ -2733,8 +2829,8 @@ const { pathToFileURL } = require('url');
2733
2829
  }
2734
2830
 
2735
2831
  ensureDir(statePath);
2736
- fs.writeFileSync(statePath, JSON.stringify(reusedState));
2737
- appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
2832
+ writeJsonFileAtomic(statePath, JSON.stringify(reusedState));
2833
+ await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
2738
2834
  state: reusedState,
2739
2835
  }));
2740
2836
 
@@ -2822,8 +2918,8 @@ const { pathToFileURL } = require('url');
2822
2918
  ...(helpers ? { helpers } : {}),
2823
2919
  routeSummary: compactRouteSummary(routeSummary),
2824
2920
  };
2825
- fs.writeFileSync(statePath, JSON.stringify(sharedState));
2826
- appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
2921
+ writeJsonFileAtomic(statePath, JSON.stringify(sharedState));
2922
+ await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
2827
2923
  routingContext,
2828
2924
  routeSummary,
2829
2925
  state: sharedState,
@@ -79,7 +79,68 @@ const { pathToFileURL } = require('url');
79
79
  // URL images are checked first and stripped out so the local-path regex never
80
80
  // re-matches the tail of a URL (case c is a hint-only lane — no download here).
81
81
  const URL_IMAGE_RE = /https?:\/\/\S+\.(?:png|jpe?g|gif|webp)\b/gi;
82
- const LOCAL_PATH_RE = /\S+\.(?:png|jpe?g|gif|webp|bmp)\b/gi;
82
+ // Local path regex deliberately tolerates a trailing run of punctuation /
83
+ // brackets / quotes after the extension. A bare \b would stop at the first
84
+ // closing quote and drop `'.` from `'/tmp/x.png'.`, leaving an unpairable
85
+ // leading quote for stripPathNoise to fail on. The char class [PUNCT] is
86
+ // hand-picked: it never matches a word char, so it still refuses to consume
87
+ // `image.pngbar` (false image) — the class doesn't include letters/digits.
88
+ // The negative lookahead `(?![A-Za-z0-9])` between the extension and the
89
+ // punct class is what actually enforces "extension terminates at a
90
+ // non-alphanumeric boundary": without it, `\S+` would backtrack past
91
+ // `image.png` inside `image.pngbar` and emit a false positive match (which
92
+ // then trips the unresolved-path advisory on every prompt of every
93
+ // installed project). The lookahead keeps the trailing-punctuation
94
+ // tolerance unchanged for real paths.
95
+ const LOCAL_PATH_RE = /\S+\.(?:png|jpe?g|gif|webp|bmp)(?![A-Za-z0-9])[()\[\]{}.,;:!?'"`]*/gi;
96
+
97
+ // Mirror of extract-image.mjs's stripPathNoise() — the regex above greedily
98
+ // captures trailing punctuation/quotes an LLM sometimes leaves on a named
99
+ // path (`/tmp/x.png'.`, `(/tmp/x.png).`, `"/tmp/x.png"`). Without this
100
+ // pre-clean the extractor receives `'/tmp/x.png` (leading quote, no close
101
+ // pair) and silently skips it. Kept here as a small inline copy rather than
102
+ // a cross-file import — the contract is a few lines, the cost of drift is
103
+ // small, and the hook must stay self-contained (no module resolution).
104
+ function stripPathNoise(raw) {
105
+ const value = String(raw ?? '').trim();
106
+ if (!value) return value;
107
+ const pairs = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'" };
108
+ const trailingSoftPunctRe = /[.,;:]+$/;
109
+ const trailingAnyPunctRe = /[.,;:)\]}"']+$/;
110
+ const candidates = [];
111
+ candidates.push(value);
112
+ const noSoft = value.replace(trailingSoftPunctRe, '');
113
+ if (noSoft !== value) candidates.push(noSoft);
114
+ if (value.length >= 2) {
115
+ const o = value[0];
116
+ const c = value[value.length - 1];
117
+ if (pairs[o] === c) candidates.push(value.slice(1, -1));
118
+ }
119
+ if (value.length >= 2) {
120
+ const o = value[0];
121
+ const c = value[value.length - 1];
122
+ if (pairs[o] === c) {
123
+ const inner = value.slice(1, -1);
124
+ const innerTrim = inner.replace(trailingAnyPunctRe, '');
125
+ if (innerTrim !== inner) candidates.push(innerTrim);
126
+ if (innerTrim !== value.slice(1, -1)) candidates.push(innerTrim);
127
+ }
128
+ }
129
+ if (noSoft.length >= 2) {
130
+ const o = noSoft[0];
131
+ const c = noSoft[noSoft.length - 1];
132
+ if (pairs[o] === c) candidates.push(noSoft.slice(1, -1));
133
+ }
134
+ for (const c of candidates) {
135
+ if (!c) continue;
136
+ try {
137
+ if (fs.existsSync(path.resolve(c))) return c;
138
+ } catch {
139
+ // ignore
140
+ }
141
+ }
142
+ return noSoft !== value ? noSoft : value;
143
+ }
83
144
 
84
145
  const urlMatches = promptText.match(URL_IMAGE_RE) || [];
85
146
  const textWithoutUrls = promptText.replace(URL_IMAGE_RE, ' ');
@@ -100,7 +161,10 @@ const { pathToFileURL } = require('url');
100
161
  args.push('--session-id', payload.session_id.trim());
101
162
  }
102
163
  for (const ref of [...localMatches, ...urlMatches]) {
103
- args.push('--ref', ref);
164
+ // Mirror extractor's resolveRefs input contract: the LLM sometimes leaves
165
+ // trailing punctuation/quotes on a path named in a prompt. Clean before
166
+ // handing off so the hook and the extractor agree on the ref string.
167
+ args.push('--ref', stripPathNoise(ref));
104
168
  }
105
169
  try {
106
170
  const out = execFileSync('node', args, {
@@ -114,24 +178,31 @@ const { pathToFileURL } = require('url');
114
178
  }
115
179
  }
116
180
 
117
- // Decide from what the EXTRACTOR did, not from prompt-text regex hits: `written`
118
- // markers are genuinely new work; `alreadyAnalyzed` entries are closed cases that
119
- // must stay silent. Regex mentions alone no longer trigger the full hint.
120
- const images = Array.isArray(extractorJson?.images) ? extractorJson.images : [];
181
+ // TASK-006 contract: in --mark-pending --json mode `images[]` is the
182
+ // MATERIALIZED-ONLY shape and stays empty; armed markers live in
183
+ // `pendingMarkers[]` with `{path, sha, bytes: 0, source, ref}`. The hook
184
+ // keys its armed/not-armed decision off `pendingMarkers[]`, never off
185
+ // `images[]` — the latter would silently report zero armed for the
186
+ // mark-pending path that is the entire point of this hook.
187
+ const pendingMarkers = Array.isArray(extractorJson?.pendingMarkers)
188
+ ? extractorJson.pendingMarkers
189
+ : [];
121
190
  const analyzed = Array.isArray(extractorJson?.alreadyAnalyzed) ? extractorJson.alreadyAnalyzed : [];
122
191
  const sessionId = typeof extractorJson?.sessionId === 'string' && extractorJson.sessionId
123
192
  ? extractorJson.sessionId
124
193
  : '';
125
- const armedCount = images.length;
126
- const armedRefs = new Set(images.filter((img) => typeof img?.ref === 'string').map((img) => img.ref));
194
+ const armedCount = pendingMarkers.length;
195
+ const armedRefs = new Set(
196
+ pendingMarkers.filter((m) => typeof m?.ref === 'string').map((m) => m.ref)
197
+ );
127
198
  const analyzedRefs = new Set(analyzed.filter((a) => typeof a?.ref === 'string').map((a) => a.ref));
128
199
  const unresolvedLocal = localMatches.filter(
129
200
  (m) => !armedRefs.has(m.trim()) && !analyzedRefs.has(m.trim())
130
201
  );
131
202
  const cases = [];
132
- if (images.some((img) => !img?.source)) cases.push('pasted image');
133
- if (images.some((img) => img?.source === 'path')) cases.push('local file path');
134
- if (images.some((img) => img?.source === 'url')) cases.push('image URL');
203
+ if (pendingMarkers.some((m) => !m?.source)) cases.push('pasted image');
204
+ if (pendingMarkers.some((m) => m?.source === 'path')) cases.push('local file path');
205
+ if (pendingMarkers.some((m) => m?.source === 'url')) cases.push('image URL');
135
206
 
136
207
  if (armedCount === 0) {
137
208
  // No NEW markers. Only speak up when a named path never resolved at all —
@@ -148,27 +219,77 @@ const { pathToFileURL } = require('url');
148
219
  return;
149
220
  }
150
221
 
151
- // Only unicMode true or null reach here; off-gateway already exited. There is no
152
- // fallback-model branch to advertise. There must never be one: the analyst
153
- // self-reports the model it really ran on, so telling it to claim some other ID is
154
- // asking it to falsify the receipt.
155
- let unicNote = '';
222
+ // Forward the parent transcript path so step 1 below targets the parent
223
+ // transcript, NOT whatever transcript happens to live in the parent's
224
+ // session dir after a subagent spawn (which is empty and silently zero).
225
+ // No path here ever came from `pendingMarkers[].path` — that field is a
226
+ // pending-<sha>.json marker receipt and must never reach the hint.
227
+ const transcriptPath = typeof payload?.transcript_path === 'string' && payload.transcript_path.trim()
228
+ ? payload.transcript_path.trim()
229
+ : '';
230
+ const materializeArgv = ['node', '.claude/ukit/index/extract-image.mjs', '--json'];
231
+ if (transcriptPath) {
232
+ materializeArgv.push('--session', transcriptPath);
233
+ }
234
+ const materializeCmd = materializeArgv
235
+ .map((tok) => (/[\s'"\\$`]/.test(tok) ? `'${tok.replace(/'/g, `'\\''`)}'` : tok))
236
+ .join(' ');
237
+
238
+ // Only unicMode true or null reach here. Resolve the gateway advisory ONCE
239
+ // here so the hint can pick a lane-aware shape. Missing `aliasAvailable`
240
+ // (older gateway) keeps today's behavior; strict `aliasAvailable === false`
241
+ // forces the native-read fallback rather than dispatching a specialist that
242
+ // the gateway itself says is unavailable.
243
+ let gatewayResult = null;
156
244
  if (unicMode === true) {
157
245
  try {
158
246
  const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
159
247
  const mod = await import(pathToFileURL(gatewayPath).href);
160
- const result = mod.detectUnicGateway({ rootDir: projectRoot });
161
- if (result?.visionModel) {
162
- unicNote = ` (UNIC gateway active — ${result.visionModel} routes through it.)`;
248
+ if (typeof mod.detectUnicGateway === 'function') {
249
+ gatewayResult = mod.detectUnicGateway({ rootDir: projectRoot });
163
250
  }
164
251
  } catch {
165
- unicNote = '';
252
+ gatewayResult = null;
166
253
  }
167
254
  }
255
+ const aliasAvailable = gatewayResult?.aliasAvailable;
256
+ const laneUnavailable = aliasAvailable === false;
257
+
258
+ if (laneUnavailable) {
259
+ // Lane cannot be honored — say so, prefer native read, do NOT instruct
260
+ // dispatching the analyst (a forced dispatch to a model the gateway
261
+ // itself reports as unavailable is the failure mode that closed the
262
+ // vision lane in the field).
263
+ const lines = [
264
+ `UKIT VISION ROUTE — new image input detected (${cases.join(', ')}).`,
265
+ 'Vision lane unavailable (unic-vision alias is not in the gateway catalog).',
266
+ 'Read the image directly with your VERIFIED native vision — do NOT dispatch',
267
+ `ukit-vision-analyst: the gateway reports the alias as unavailable, and a`,
268
+ 'forced dispatch would fail at the provider.',
269
+ '',
270
+ `Materialize with: ${materializeCmd}`,
271
+ 'Then Read each ABSOLUTE path in images[].path as text (subagents do NOT',
272
+ `inherit image blocks; they can only Read files). For pasted images the`,
273
+ `--session arg above is required — without it the extractor picks the`,
274
+ 'newest transcript in the session dir, which after a subagent spawn is',
275
+ 'the empty subagent transcript.',
276
+ ];
277
+ if (sessionId) {
278
+ lines.push(`Markers armed under sessionId: ${sessionId}; receipts land as analyzed-<sha>.json.`);
279
+ }
280
+ process.stdout.write(`${lines.join('\n')}\n`);
281
+ process.exit(0);
282
+ return;
283
+ }
284
+
285
+ // Lane honored (or unverified — missing aliasAvailable defaults to today's
286
+ // unic-vision dispatch). Capability-framed, never provider identity: the
287
+ // remedy is "don't guess — use a verified reader", which holds whether the
288
+ // active model reads images natively or hands off to the specialist.
289
+ const unicNote = (unicMode === true && gatewayResult?.visionModel)
290
+ ? ` (UNIC gateway active — ${gatewayResult.visionModel} routes through it.)`
291
+ : '';
168
292
 
169
- // Capability-framed, never provider identity: the remedy is "don't guess — use a
170
- // verified reader", which holds whether the active model reads images natively or
171
- // must hand off to the specialist.
172
293
  const reasonLines = [
173
294
  'Advisory: never guess at image contents. If the active model has VERIFIED native',
174
295
  'vision for these images it may read them directly; otherwise dispatch the specialist',
@@ -178,7 +299,9 @@ const { pathToFileURL } = require('url');
178
299
  const lines = [
179
300
  `UKIT VISION ROUTE — new image input detected (${cases.join(', ')}).`,
180
301
  ...reasonLines,
181
- ' 1. node .claude/ukit/index/extract-image.mjs --json',
302
+ ` 1. ${materializeCmd}`,
303
+ ' Materialize the images to disk. With --session the extractor targets the',
304
+ ' PARENT transcript explicitly, so a subagent spawn cannot drop the image.',
182
305
  ' 2. Agent(subagent_type: "ukit-vision-analyst") [model: unic-vision]',
183
306
  ' Send the ABSOLUTE paths from images[].path as TEXT (subagents do NOT inherit',
184
307
  ' image blocks; they can only Read files). Include the task envelope: the ORIGINAL',
@@ -189,7 +312,7 @@ const { pathToFileURL } = require('url');
189
312
  if (sessionId) {
190
313
  lines.push(` Markers armed under sessionId: ${sessionId} (receipts: analyzed-<sha>.json).`);
191
314
  }
192
- if (images.some((img) => img?.source === 'url')) {
315
+ if (pendingMarkers.some((m) => m?.source === 'url')) {
193
316
  lines.push(' Image URL detected: ukit-vision-analyst downloads it with Bash into');
194
317
  lines.push(' .ukit/storage/cache/vision/, then Reads the downloaded file.');
195
318
  }
@@ -67,6 +67,76 @@ const EXT_MEDIA = {
67
67
  bmp: 'image/bmp',
68
68
  };
69
69
 
70
+ /**
71
+ * Strips wrapping quotes / matching parens / trailing punctuation that an LLM
72
+ * sometimes leaves on a path named in a prompt (e.g. `/tmp/x.png'.`,
73
+ * `(/tmp/x.png).`, `"/tmp/x.png"`). The interior of the path is left alone.
74
+ *
75
+ * Strategy: try the original and several cleaned shapes; return the first one
76
+ * that `fs.existsSync`s. Order is chosen to cover the common artefacts
77
+ * without consuming a wrapping pair's close-bracket as "trailing punctuation":
78
+ * 1. the raw value (a clean absolute path needs no stripping)
79
+ * 2. strip a single trailing run of `.` `,` `;` `:` (cheap; covers `path.`)
80
+ * 3. strip a matching wrapping pair (covers `"path"` and `'path'`)
81
+ * 4. strip the wrapping pair AND any leftover trailing punct
82
+ * (covers `"/tmp/x.png".` and `'/tmp/x.png',`)
83
+ * 5. strip a single trailing `)` `]` `}` (the open was a wrap; the close
84
+ * got left over after a stray final char) then strip the matching pair
85
+ * (covers `(/tmp/x.png).` where the `.` already ate the `)`)
86
+ */
87
+ function stripPathNoise(raw) {
88
+ const value = String(raw ?? '').trim();
89
+ if (!value) return value;
90
+ const pairs = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'" };
91
+ const trailingSoftPunctRe = /[.,;:]+$/; // won't eat a wrapping pair's close
92
+ const trailingAnyPunctRe = /[.,;:)\]}"']+$/;
93
+ const candidates = [];
94
+
95
+ candidates.push(value);
96
+ // (2) drop a soft trailing punct run only — won't consume a wrap-close.
97
+ const noSoft = value.replace(trailingSoftPunctRe, '');
98
+ if (noSoft !== value) candidates.push(noSoft);
99
+ // (3) drop a matching wrapping pair if both ends match.
100
+ if (value.length >= 2) {
101
+ const o = value[0];
102
+ const c = value[value.length - 1];
103
+ if (pairs[o] === c) candidates.push(value.slice(1, -1));
104
+ }
105
+ // (4) drop a wrapping pair, THEN any leftover trailing punct.
106
+ if (value.length >= 2) {
107
+ const o = value[0];
108
+ const c = value[value.length - 1];
109
+ if (pairs[o] === c) {
110
+ const inner = value.slice(1, -1);
111
+ const innerTrim = inner.replace(trailingAnyPunctRe, '');
112
+ if (innerTrim !== inner) candidates.push(innerTrim);
113
+ if (innerTrim !== value.slice(1, -1)) candidates.push(innerTrim);
114
+ }
115
+ }
116
+ // (5) drop a soft trailing punct, THEN a matching wrapping pair (covers
117
+ // `(/tmp/x.png).` — soft strip eats `.` then pair strip takes `()`).
118
+ if (noSoft.length >= 2) {
119
+ const o = noSoft[0];
120
+ const c = noSoft[noSoft.length - 1];
121
+ if (pairs[o] === c) candidates.push(noSoft.slice(1, -1));
122
+ }
123
+
124
+ const seen = new Set();
125
+ for (const c of candidates) {
126
+ if (!c || seen.has(c)) continue;
127
+ seen.add(c);
128
+ try {
129
+ if (fs.existsSync(path.resolve(c))) return c;
130
+ } catch {
131
+ // ignore
132
+ }
133
+ }
134
+ // No candidate resolved to a real file. Return the soft-stripped shape so
135
+ // the caller at least gets a deterministic string and the existing "file
136
+ // not found" branch reports the failure on the cleaned shape.
137
+ return noSoft !== value ? noSoft : value;
138
+ }
139
+
70
140
  /**
71
141
  * Turns --ref values (local paths and URLs named in the prompt) into markers.
72
142
  *
@@ -76,24 +146,30 @@ const EXT_MEDIA = {
76
146
  * analyzed-<sha> by filename and never recomputes it. Still routed through
77
147
  * crypto.createHash here so this file remains the single owner of the hash.
78
148
  *
149
+ * Tolerates absolute paths OUTSIDE the project root (a vision repro sometimes
150
+ * points at a file outside `process.cwd()`), and strips wrapping quotes / trailing
151
+ * punctuation that an LLM may leave on a path named in a prompt — see
152
+ * stripPathNoise() above.
153
+ *
79
154
  * Silently drops anything that is not an image ref or cannot be read: this feeds a
80
155
  * hook that must never wedge a prompt.
81
156
  */
82
157
  export function resolveRefs(values) {
83
158
  const out = [];
84
159
  const seen = new Set();
85
- for (const value of values ?? []) {
86
- const match = REF_EXT_RE.exec(value);
160
+ for (const raw of values ?? []) {
161
+ const cleaned = stripPathNoise(raw);
162
+ const match = REF_EXT_RE.exec(cleaned);
87
163
  if (!match) continue;
88
164
  const mediaType = EXT_MEDIA[match[1].toLowerCase()] ?? 'application/octet-stream';
89
- const isUrl = /^https?:\/\//i.test(value);
165
+ const isUrl = /^https?:\/\//i.test(cleaned);
90
166
  let sha;
91
167
  if (isUrl) {
92
- sha = crypto.createHash('sha256').update(value, 'utf8').digest('hex');
168
+ sha = crypto.createHash('sha256').update(cleaned, 'utf8').digest('hex');
93
169
  } else {
94
170
  let bytes;
95
171
  try {
96
- bytes = fs.readFileSync(path.resolve(value));
172
+ bytes = fs.readFileSync(path.resolve(cleaned));
97
173
  } catch {
98
174
  continue; // path named in the prompt but not present on disk: nothing to analyse
99
175
  }
@@ -102,7 +178,7 @@ export function resolveRefs(values) {
102
178
  }
103
179
  if (seen.has(sha)) continue;
104
180
  seen.add(sha);
105
- out.push({ sha, mediaType, source: isUrl ? 'url' : 'path', value });
181
+ out.push({ sha, mediaType, source: isUrl ? 'url' : 'path', value: cleaned });
106
182
  }
107
183
  return out;
108
184
  }
@@ -149,21 +225,116 @@ function parseArgs(argv) {
149
225
  }
150
226
 
151
227
  function slugifyCwd(cwd) {
152
- return cwd.replace(/[^a-zA-Z0-9]/g, '-');
228
+ // realpathSync collapses `/var` ↔ `/private/var` symlinks (and any other
229
+ // platform-specific canonicalisation) so the slug we compute here is the
230
+ // same string the Claude Code daemon uses to name the same sessions dir.
231
+ // Without this, on macOS process.cwd() can be `/private/var/folders/...`
232
+ // while the on-disk dir the daemon queries is `/var/folders/...`, and the
233
+ // slugs drift apart — silent miss.
234
+ let canonical = cwd;
235
+ try {
236
+ canonical = fs.realpathSync(cwd);
237
+ } catch {
238
+ // cwd may not exist (rare, e.g. a deleted-then-recreated cwd); fall back
239
+ // to the raw path so the script still produces a deterministic slug.
240
+ }
241
+ return canonical.replace(/[^a-zA-Z0-9]/g, '-');
242
+ }
243
+
244
+ /**
245
+ * How many newest candidates to scan before giving up on default discovery.
246
+ *
247
+ * The vision failure we're fixing here had the parent Claude Code spawning a
248
+ * subagent whose own transcript is the newest file in the sessions dir — but
249
+ * the subagent's transcript has zero images, while the parent's (older) one
250
+ * has the pasted image. Picking the *very* newest mtime alone returns the
251
+ * subagent's empty transcript and silently drops the image.
252
+ *
253
+ * The fix is a bounded newest-first walk: scan at most the N newest .jsonl
254
+ * candidates and pick the first one that actually contains an image block. N
255
+ * is kept small on purpose: this hook fires on every UserPromptSubmit, so we
256
+ * refuse to do an unbounded directory walk or a full-content parse of every
257
+ * candidate. Pinned via DISCOVERY_SCAN_BOUND so the test suite can verify
258
+ * the bound is enforced (TASK-006 case 7).
259
+ */
260
+ const DISCOVERY_SCAN_BOUND = 5;
261
+
262
+ /** Cheap "does this transcript look like it has any image blocks?" check.
263
+ *
264
+ * Reads at most 64 KiB from the head of the file (peeks via fs.read with a
265
+ * bounded length — no full-content parse, no JSON.parse) and looks for the
266
+ * `"type":"image"` JSON property the extractor uses to recognise pasted
267
+ * image blocks. False negatives are tolerated: the caller treats a miss as
268
+ * "not image-bearing" and moves on to the next candidate. False positives
269
+ * are NOT tolerated silently: the caller MUST confirm with a full parse
270
+ * (extractImageBlocks), because a transcript's text or sibling keys can
271
+ * legitimately contain the literal substring `"type":"image"` without any
272
+ * real image block — see the fall-through in discoverSessionFile().
273
+ */
274
+ function transcriptHasImageHead(filePath) {
275
+ let fd = null;
276
+ try {
277
+ fd = fs.openSync(filePath, 'r');
278
+ const stats = fs.fstatSync(fd);
279
+ const bytesToRead = Math.min(stats.size, 64 * 1024);
280
+ if (bytesToRead <= 0) return false;
281
+ const buf = Buffer.alloc(bytesToRead);
282
+ fs.readSync(fd, buf, 0, bytesToRead, 0);
283
+ // The extractor emits JSONL with each line as one JSON object; the
284
+ // pasted-image property is `"type":"image"` (no spaces — same shape
285
+ // Claude Code writes today). Match both `"type":"image"` and the
286
+ // canonical-with-spaces shape for resilience.
287
+ return buf.includes(Buffer.from('"type":"image"'))
288
+ || buf.includes(Buffer.from('"type": "image"'));
289
+ } catch {
290
+ return false;
291
+ } finally {
292
+ if (fd !== null) {
293
+ try { fs.closeSync(fd); } catch { /* ignore */ }
294
+ }
295
+ }
153
296
  }
154
297
 
298
+ /**
299
+ * Pick a transcript .jsonl to scan for images.
300
+ *
301
+ * Returns `{ path, scanned }` where `scanned` is the ordered list of
302
+ * candidates the bounded walk actually inspected (newest-first, capped at
303
+ * DISCOVERY_SCAN_BOUND). On success `path` is the first candidate whose
304
+ * full JSONL parse yielded at least one image block. On failure `path` is
305
+ * `null` and `scanned` still carries the candidates that were checked so
306
+ * the caller can emit an explicit `STATUS: NO_IMAGE` message that names
307
+ * them.
308
+ *
309
+ * The head-peek is a cheap prefilter, not a promise. A transcript can pass
310
+ * the head-peek (its raw bytes contain the literal substring `"type":"image"`)
311
+ * and still parse to ZERO real image blocks — e.g., a log line that quotes
312
+ * the JSON shape, or a sibling top-level key/value pair where `"type":"image"`
313
+ * appears as structural bytes. In that case we fall through to the next
314
+ * bounded candidate instead of early-stopping. Without this, main() prints
315
+ * a silent imageCount: 0 and never reaches the explicit NO_IMAGE branch the
316
+ * vision router relies on to tell "tried, no images" from a real failure.
317
+ *
318
+ * The sessions root honours `CLAUDE_CONFIG_DIR` when set (the standard
319
+ * Claude Code env var) so contributors / tests can redirect the lookup
320
+ * without touching `~/.claude`. Default stays `os.homedir()/.claude` so
321
+ * production behaviour is unchanged.
322
+ */
155
323
  function discoverSessionFile(cwd) {
156
324
  const slug = slugifyCwd(cwd);
157
- const dir = path.join(os.homedir(), '.claude', 'projects', slug);
325
+ const configRoot = process.env.CLAUDE_CONFIG_DIR
326
+ ? path.resolve(process.env.CLAUDE_CONFIG_DIR)
327
+ : path.join(os.homedir(), '.claude');
328
+ const dir = path.join(configRoot, 'projects', slug);
329
+
158
330
  let entries;
159
331
  try {
160
332
  entries = fs.readdirSync(dir, { withFileTypes: true });
161
333
  } catch {
162
- return null;
334
+ return { path: null, scanned: [] };
163
335
  }
164
336
 
165
- let newestPath = null;
166
- let newestMtimeMs = -Infinity;
337
+ const candidates = [];
167
338
  for (const entry of entries) {
168
339
  if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
169
340
  const full = path.join(dir, entry.name);
@@ -173,12 +344,31 @@ function discoverSessionFile(cwd) {
173
344
  } catch {
174
345
  continue;
175
346
  }
176
- if (stat.mtimeMs > newestMtimeMs) {
177
- newestMtimeMs = stat.mtimeMs;
178
- newestPath = full;
347
+ candidates.push({ full, mtimeMs: stat.mtimeMs });
348
+ }
349
+ // Newest first, bounded.
350
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
351
+ const bounded = candidates.slice(0, DISCOVERY_SCAN_BOUND);
352
+ const scanned = bounded.map((c) => path.basename(c.full));
353
+
354
+ for (const candidate of bounded) {
355
+ if (!transcriptHasImageHead(candidate.full)) continue;
356
+ // The head-peek is a cheap prefilter, not a promise: a transcript can
357
+ // contain the literal substring `"type":"image"` in prose (e.g., a log
358
+ // line that quotes the JSON shape, or a sibling top-level key/value pair
359
+ // where `"type":"image"` appears as structural bytes) yet parse to ZERO
360
+ // real image blocks. The full JSONL parse is the gate — a zero-block
361
+ // result must fall through to the next bounded candidate instead of
362
+ // early-stopping. Otherwise main() prints a silent imageCount: 0 and
363
+ // never reaches the explicit NO_IMAGE branch the vision router relies
364
+ // on to tell "tried, no images" from a real failure.
365
+ const lines = readLines(candidate.full);
366
+ const blocks = extractImageBlocks(lines);
367
+ if (blocks.length > 0) {
368
+ return { path: candidate.full, scanned };
179
369
  }
180
370
  }
181
- return newestPath;
371
+ return { path: null, scanned };
182
372
  }
183
373
 
184
374
  function readLines(filePath) {
@@ -302,11 +492,35 @@ function printResult(opts, result) {
302
492
  }
303
493
  }
304
494
 
495
+ /**
496
+ * Surface a non-silent refusal when default discovery couldn't find an
497
+ * image-bearing transcript. The caller (vision-router.sh) relies on this to
498
+ * distinguish "nothing to analyse" from a real failure, so we always print
499
+ * the message — both in `--json` (via a STATUS: NO_IMAGE marker line that
500
+ * the contract layer can read) and in human-readable mode.
501
+ */
502
+ function emitNoImage(scanned, sessionId, opts) {
503
+ const names = scanned.length > 0 ? scanned.join(', ') : '(empty)';
504
+ if (opts.json) {
505
+ // Two-line shape: the first is machine-parseable (imageCount: 0, images: []),
506
+ // the second is the explicit refusal marker so callers/tests can assert on it
507
+ // without parsing the array contents.
508
+ console.log(JSON.stringify({ imageCount: 0, sessionId, images: [] }));
509
+ console.log(`STATUS: NO_IMAGE scanned=${names}`);
510
+ return;
511
+ }
512
+ console.log(`STATUS: NO_IMAGE scanned=${names}`);
513
+ }
514
+
305
515
  function main() {
306
516
  const opts = parseArgs(process.argv.slice(2));
307
517
  const cwd = process.cwd();
308
518
 
309
- const sessionPath = opts.session ? path.resolve(opts.session) : discoverSessionFile(cwd);
519
+ const discovery = opts.session
520
+ ? { path: path.resolve(opts.session), scanned: [] }
521
+ : discoverSessionFile(cwd);
522
+ const sessionPath = discovery.path;
523
+ const scannedNames = discovery.scanned;
310
524
  const outDir = opts.out
311
525
  ? path.resolve(opts.out)
312
526
  : path.join(cwd, '.ukit', 'storage', 'cache', 'vision');
@@ -314,7 +528,23 @@ function main() {
314
528
  const sessionId = opts.sessionId
315
529
  || (sessionPath ? path.basename(sessionPath, path.extname(sessionPath)) : 'default');
316
530
 
317
- const lines = sessionPath ? readLines(sessionPath) : [];
531
+ // Default discovery could not find an image-bearing transcript within the
532
+ // bounded scan — surface an explicit refusal instead of a silent zero so
533
+ // the caller can tell "tried, no images" from a real failure. Explicit
534
+ // --session still bypasses this: the caller knows what they asked for.
535
+ if (!sessionPath) {
536
+ if (scannedNames.length > 0) {
537
+ emitNoImage(scannedNames, sessionId, opts);
538
+ } else {
539
+ // No candidates at all (sessions dir missing or empty). Preserve the
540
+ // original zero-output behaviour so existing callers don't see a new
541
+ // refusal shape for a pre-existing "no home / no projects" case.
542
+ printResult(opts, { imageCount: 0, sessionId, images: [] });
543
+ }
544
+ return;
545
+ }
546
+
547
+ const lines = readLines(sessionPath);
318
548
  const blocks = extractImageBlocks(lines);
319
549
  const markerDir = path.join(outDir, sessionId);
320
550
  // selectImages() picks the most recent N images from the WHOLE transcript, so a resolved
@@ -384,7 +614,16 @@ function main() {
384
614
  }
385
615
  }
386
616
  pruneMarkerDirs(outDir);
387
- printResult(opts, { imageCount: written.length, sessionId, images: written, alreadyAnalyzed });
617
+ // images[] invariant: marker receipts (bytes: 0, .json path) NEVER belong
618
+ // here — they live in pendingMarkers[]. images[] is the materialized-only
619
+ // shape; detect/mark-pending mode keeps it empty by definition.
620
+ printResult(opts, {
621
+ imageCount: written.length,
622
+ sessionId,
623
+ images: [],
624
+ pendingMarkers: written,
625
+ alreadyAnalyzed,
626
+ });
388
627
  return;
389
628
  }
390
629
 
@@ -425,6 +664,8 @@ function main() {
425
664
  images.push({ sha: img.sha, path: filePath, mediaType: img.mediaType, bytes: decoded.length });
426
665
  }
427
666
  pruneImageFiles(outDir);
667
+ // images[] invariant: every entry has bytes > 0 and a non-.json extension —
668
+ // materialized image files only, never markers.
428
669
  printResult(opts, { imageCount: images.length, sessionId, images });
429
670
  }
430
671
 
@@ -93,10 +93,21 @@ function detectPastedImageViaExtractor({ rootDir = process.cwd() } = {}) {
93
93
  }
94
94
 
95
95
  /**
96
- * Gateway-aware model pick (TASK-003 aware). Calls detectUnicGateway() directly (not the CLI) to
97
- * avoid a spawn on the hot path. unicMode true -> unic-vision. unicMode false -> read
98
- * orchestration.modelTiers.vision.fallbackModel from config (TASK-001). Neither available -> omit
99
- * visionModel and return an advisory; a blind model is never silently named.
96
+ * Gateway-aware model pick (TASK-003 aware; TASK-008 lane-fallback). Calls detectUnicGateway()
97
+ * directly (not the CLI) to avoid a spawn on the hot path.
98
+ *
99
+ * Policy (mirrors the hook side in TASK-007 / ws-g.sh):
100
+ * - unicMode true AND aliasAvailable true -> unic-vision (today's behavior).
101
+ * - unicMode true AND aliasAvailable false -> fall through to config fallback (do NOT force a
102
+ * failing dispatch to a model the gateway itself says is unavailable); state the
103
+ * unavailability in visionAdvisory so callers skip the lane and prefer native read.
104
+ * - unicMode true AND aliasAvailable missing/undefined -> keep today's behavior
105
+ * (return unic-vision). The strict === false check is what preserves the "missing field
106
+ * doesn't change today's behavior" contract from the task spec.
107
+ * - unicMode false -> read orchestration.modelTiers.vision.fallbackModel from config
108
+ * (TASK-001).
109
+ * - Neither available -> omit visionModel and return an advisory; a blind model is never
110
+ * silently named.
100
111
  */
101
112
  async function resolveVisionModel({ rootDir = process.cwd() } = {}) {
102
113
  let gateway = null;
@@ -106,12 +117,29 @@ async function resolveVisionModel({ rootDir = process.cwd() } = {}) {
106
117
  gateway = null;
107
118
  }
108
119
 
120
+ const config = await readJson(path.join(rootDir, '.ukit', 'storage', 'config.json'), null);
121
+ const fallbackModel = config?.orchestration?.modelTiers?.vision?.fallbackModel;
122
+
123
+ if (gateway?.unicMode && gateway.aliasAvailable === false) {
124
+ if (typeof fallbackModel === 'string' && fallbackModel.trim()) {
125
+ return {
126
+ visionModel: fallbackModel.trim(),
127
+ visionAdvisory: 'vision lane detected but unic-vision alias is unavailable in the gateway '
128
+ + 'catalog; falling back to orchestration.modelTiers.vision.fallbackModel.',
129
+ };
130
+ }
131
+ return {
132
+ visionModel: null,
133
+ visionAdvisory: 'vision lane detected but unic-vision alias is unavailable in the gateway '
134
+ + 'catalog and orchestration.modelTiers.vision.fallbackModel is not configured; no model '
135
+ + 'was named to avoid forcing a failing dispatch — caller should prefer native read.',
136
+ };
137
+ }
138
+
109
139
  if (gateway?.unicMode) {
110
140
  return { visionModel: gateway.visionModel || 'unic-vision', visionAdvisory: null };
111
141
  }
112
142
 
113
- const config = await readJson(path.join(rootDir, '.ukit', 'storage', 'config.json'), null);
114
- const fallbackModel = config?.orchestration?.modelTiers?.vision?.fallbackModel;
115
143
  if (typeof fallbackModel === 'string' && fallbackModel.trim()) {
116
144
  return { visionModel: fallbackModel.trim(), visionAdvisory: null };
117
145
  }