create-egregore 0.21.0-runtime-mvp.2 → 0.21.0-runtime-mvp.4

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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // egregore-launcher-version: 0.21.0-runtime-mvp.2
2
+ // egregore-launcher-version: 0.21.0-runtime-mvp.4
3
3
  //
4
4
  // egregore — launch and browse Egregore instances from your terminal.
5
5
  // Installed by create-egregore. Reads ~/.egregore/instances.json.
@@ -35,7 +35,7 @@ const crypto = require("node:crypto");
35
35
  const { spawnSync, spawn } = require("node:child_process");
36
36
  const readline = require("node:readline");
37
37
 
38
- const VERSION = "0.21.0-runtime-mvp.2";
38
+ const VERSION = "0.21.0-runtime-mvp.4";
39
39
  const REGISTRY = path.join(os.homedir(), ".egregore", "instances.json");
40
40
  const DEFAULT_FILE = path.join(os.homedir(), ".egregore", "default");
41
41
  const UPDATE_CACHE_FILE = path.join(os.homedir(), ".egregore", "update-check.json");
@@ -2505,6 +2505,40 @@ function upgradeStatusAsync(inst, cb) {
2505
2505
  } catch { cb(null); }
2506
2506
  }
2507
2507
 
2508
+ // Local retrieval health for the settings surface. Package-update state,
2509
+ // Runtime activation state, and retrieval health are three different truths;
2510
+ // the settings row must not let one stand in for another. Repair-needed means
2511
+ // readiness names a concrete repair command (e.g. the embedding model is
2512
+ // missing while stored vectors are intact).
2513
+ function retrievalHealthAsync(inst, cb) {
2514
+ const script = path.join(inst.path, "bin", "search.sh");
2515
+ if (!fs.existsSync(script)) return cb(null);
2516
+ let buf = "";
2517
+ try {
2518
+ const child = spawn("bash", [script, "readiness", "--json"], {
2519
+ cwd: inst.path,
2520
+ stdio: ["ignore", "pipe", "ignore"],
2521
+ env: { ...process.env },
2522
+ });
2523
+ child.stdout.on("data", (d) => { buf += d; });
2524
+ child.on("close", () => {
2525
+ try {
2526
+ const report = JSON.parse(buf);
2527
+ const items = Array.isArray(report.items) ? report.items : [];
2528
+ const byKey = Object.fromEntries(items.map((item) => [item.key, item]));
2529
+ const repair = items.some((item) => /repair: bin\/search\.sh/.test(item.detail || ""));
2530
+ cb({
2531
+ repair,
2532
+ searchStage: byKey.search?.stage || "unknown",
2533
+ embeddingsDetail: byKey.embeddings?.detail || "",
2534
+ semanticDetail: byKey.semantic?.detail || "",
2535
+ });
2536
+ } catch { cb(null); }
2537
+ });
2538
+ child.on("error", () => cb(null));
2539
+ } catch { cb(null); }
2540
+ }
2541
+
2508
2542
  // Exact candidate resolution: the runtime-mvp dist-tag only, validated
2509
2543
  // against the prerelease scheme — never latest, never a retired scheme.
2510
2544
  // EGREGORE_RUNTIME_MVP_CANDIDATE is the offline/test seam.
@@ -2560,7 +2594,7 @@ function upgradeElapsed(st) {
2560
2594
  // Plain-language body shared by the non-interactive `settings <slug> runtime`
2561
2595
  // output and the interactive panel. Returns undecorated strings; callers add
2562
2596
  // color. Never JSON, never internal paths beyond the instance's own root.
2563
- function formatUpgradeLines(inst, st, { candidate = "" } = {}) {
2597
+ function formatUpgradeLines(inst, st, { candidate = "", retrieval = null } = {}) {
2564
2598
  const lines = [];
2565
2599
  const d = st || { status: "none" };
2566
2600
  lines.push(`Selected Egregore: ${inst.name || inst.slug} (${inst.slug || "?"})`);
@@ -2581,6 +2615,17 @@ function formatUpgradeLines(inst, st, { candidate = "" } = {}) {
2581
2615
  } else if (d.status !== "none") {
2582
2616
  lines.push("Previous Runtime remains active until activation.");
2583
2617
  }
2618
+ if (retrieval) {
2619
+ if (retrieval.repair) {
2620
+ lines.push("Local retrieval: repair needed");
2621
+ if (retrieval.embeddingsDetail) lines.push(` ✗ ${retrieval.embeddingsDetail}`);
2622
+ lines.push(" → run: bin/search.sh reindex --embed");
2623
+ } else if (retrieval.searchStage && retrieval.searchStage !== "ready") {
2624
+ lines.push(`Local retrieval: ${retrieval.searchStage}`);
2625
+ } else if (retrieval.searchStage === "ready") {
2626
+ lines.push("Local retrieval: ready");
2627
+ }
2628
+ }
2584
2629
  if (Array.isArray(d.stages) && d.status !== "none" && d.status !== "active") {
2585
2630
  lines.push("");
2586
2631
  for (const stage of d.stages) {
@@ -2984,7 +3029,7 @@ const SETTINGS_DOMAIN_NOUN = { repo: "repo name", admin: "github handle", people
2984
3029
  // Runtime-update panel: renders the durable upgrade state from the Python
2985
3030
  // engine. Same geometry as the settings panel; plain-language body from
2986
3031
  // formatUpgradeLines with launcher glyph decoration only.
2987
- function renderUpgradePanel({ inst, data, candidate, mode, notice, width }) {
3032
+ function renderUpgradePanel({ inst, data, candidate, retrieval, mode, notice, width }) {
2988
3033
  const outerWidth = Math.min(Math.max(50, width - 2), 126);
2989
3034
  const innerWidth = Math.max(48, outerWidth - 2);
2990
3035
  const margin = " ".repeat(Math.max(0, Math.floor((width - outerWidth) / 2)));
@@ -2997,7 +3042,7 @@ function renderUpgradePanel({ inst, data, candidate, mode, notice, width }) {
2997
3042
  ? ["Checking this egregore's Runtime state…"]
2998
3043
  : data === null
2999
3044
  ? ["Runtime update state is unavailable for this egregore."]
3000
- : formatUpgradeLines(inst, data, { candidate });
3045
+ : formatUpgradeLines(inst, data, { candidate, retrieval });
3001
3046
  for (const raw of body) {
3002
3047
  let line = raw;
3003
3048
  if (/^ ✓ /.test(raw)) line = ` ${GREEN}✓${RESET} ${raw.slice(4)}`;
@@ -3041,7 +3086,7 @@ function renderUpgradePanel({ inst, data, candidate, mode, notice, width }) {
3041
3086
  return lines;
3042
3087
  }
3043
3088
 
3044
- function renderSettingsPanel({ inst, data, cursor, view, domain, items, listCursor, mode, input, notice, width, upgrade }) {
3089
+ function renderSettingsPanel({ inst, data, cursor, view, domain, items, listCursor, mode, input, notice, width, upgrade, retrieval }) {
3045
3090
  const title = inst.slug || inst.name || path.basename(inst.path);
3046
3091
  const outerWidth = Math.min(Math.max(50, width - 2), 126);
3047
3092
  const innerWidth = Math.max(48, outerWidth - 2);
@@ -3196,7 +3241,12 @@ function renderSettingsPanel({ inst, data, cursor, view, domain, items, listCurs
3196
3241
  ? `${YELLOW}${upgradeSummary(upgrade)}${RESET}`
3197
3242
  : `${DIM}${upgradeSummary(upgrade)}${RESET}`;
3198
3243
  const rows = [
3199
- { name: "Runtime update", value: upgradeValue },
3244
+ {
3245
+ name: "Runtime update",
3246
+ value: retrieval && retrieval.repair
3247
+ ? `${upgradeValue} ${YELLOW}· retrieval repair needed${RESET}`
3248
+ : upgradeValue,
3249
+ },
3200
3250
  { name: "Privacy & access", value: `${DIM}identity · boundary · consent${RESET}` },
3201
3251
  { name: "Telemetry", value: `${DIM}personal · opt-out${RESET}` },
3202
3252
  { name: "Hosted Artifacts", value: (d.hosting !== false) ? `${GREEN}ON${RESET}` : `${YELLOW}OFF${RESET}` },
@@ -3350,6 +3400,7 @@ async function browser(args = {}) {
3350
3400
  inst: state.upgradeInst,
3351
3401
  data: state.upgradeData,
3352
3402
  candidate: state.upgradeCandidate,
3403
+ retrieval: state.upgradeRetrieval,
3353
3404
  mode: state.upgradeMode,
3354
3405
  notice: state.upgradeNotice,
3355
3406
  width: w,
@@ -3375,6 +3426,7 @@ async function browser(args = {}) {
3375
3426
  notice: state.settingsNotice,
3376
3427
  width: w,
3377
3428
  upgrade: state.settingsUpgrade,
3429
+ retrieval: state.settingsRetrieval,
3378
3430
  });
3379
3431
  for (const line of settingsLines) out.push(line + ANSI.clearLine + "\n");
3380
3432
  const remainingRows = Math.max(0, h - settingsLines.length - 1);
@@ -3903,8 +3955,13 @@ async function browser(args = {}) {
3903
3955
  if (S.settingsCursor === 0) {
3904
3956
  state.upgradeInst = S.settingsInst;
3905
3957
  state.upgradeData = S.settingsUpgrade;
3958
+ state.upgradeRetrieval = S.settingsRetrieval;
3906
3959
  state.upgradeMode = "view";
3907
3960
  state.upgradeNotice = "";
3961
+ retrievalHealthAsync(S.settingsInst, (health) => {
3962
+ state.upgradeRetrieval = health;
3963
+ if (state.upgradeInst) render();
3964
+ });
3908
3965
  refreshUpgrade();
3909
3966
  if (!state.upgradeCandidate) {
3910
3967
  fetchRuntimeMvpCandidate().then((v) => {
@@ -4190,10 +4247,15 @@ async function browser(args = {}) {
4190
4247
  state.settingsCursor = 0;
4191
4248
  state.settingsView = "menu";
4192
4249
  state.settingsUpgrade = undefined;
4250
+ state.settingsRetrieval = undefined;
4193
4251
  upgradeStatusAsync(pick, (st) => {
4194
4252
  state.settingsUpgrade = st;
4195
4253
  if (state.settingsInst === pick && !state.upgradeInst) render();
4196
4254
  });
4255
+ retrievalHealthAsync(pick, (health) => {
4256
+ state.settingsRetrieval = health;
4257
+ if (state.settingsInst === pick && !state.upgradeInst) render();
4258
+ });
4197
4259
  state.settingsNotice = "";
4198
4260
  render();
4199
4261
  return;
package/lib/setup.js CHANGED
@@ -1327,17 +1327,46 @@ function currentTemplatePath() {
1327
1327
  // # egregore-launcher-version: X.Y.Z (bash)
1328
1328
  // // egregore-launcher-version: X.Y.Z (node)
1329
1329
  function launcherVersion(contents) {
1330
- const m = contents.match(/egregore-launcher-version:\s*(\d+\.\d+\.\d+)/);
1330
+ // Capture the full stamp including any prerelease suffix. A prerelease-blind
1331
+ // regex made 0.21.0-runtime-mvp.1 and .2 both read as "0.21.0", so the
1332
+ // refresh treated the newer template as a local edit and refused to upgrade.
1333
+ const m = contents.match(/egregore-launcher-version:\s*(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/);
1331
1334
  return m ? m[1] : null;
1332
1335
  }
1333
1336
 
1334
- // Return 1 if a > b, -1 if a < b, 0 if equal. Both are semver strings "x.y.z".
1337
+ // Return 1 if a > b, -1 if a < b, 0 if equal. Semver with prerelease rules:
1338
+ // a release outranks any prerelease of the same core; prerelease identifiers
1339
+ // compare dot-by-dot, numerically when both numeric.
1335
1340
  function compareVersions(a, b) {
1336
- const pa = a.split(".").map(Number);
1337
- const pb = b.split(".").map(Number);
1341
+ const splitOnce = (v) => {
1342
+ const i = v.indexOf("-");
1343
+ return i === -1 ? [v, ""] : [v.slice(0, i), v.slice(i + 1)];
1344
+ };
1345
+ const [coreA, preA] = splitOnce(a);
1346
+ const [coreB, preB] = splitOnce(b);
1347
+ const pa = coreA.split(".").map(Number);
1348
+ const pb = coreB.split(".").map(Number);
1338
1349
  for (let i = 0; i < 3; i++) {
1339
1350
  if (pa[i] !== pb[i]) return pa[i] > pb[i] ? 1 : -1;
1340
1351
  }
1352
+ if (!preA && !preB) return 0;
1353
+ if (!preA) return 1;
1354
+ if (!preB) return -1;
1355
+ const ia = preA.split(".");
1356
+ const ib = preB.split(".");
1357
+ for (let i = 0; i < Math.max(ia.length, ib.length); i++) {
1358
+ if (ia[i] === undefined) return -1;
1359
+ if (ib[i] === undefined) return 1;
1360
+ const na = /^\d+$/.test(ia[i]) ? Number(ia[i]) : NaN;
1361
+ const nb = /^\d+$/.test(ib[i]) ? Number(ib[i]) : NaN;
1362
+ if (!Number.isNaN(na) && !Number.isNaN(nb)) {
1363
+ if (na !== nb) return na > nb ? 1 : -1;
1364
+ } else if (Number.isNaN(na) !== Number.isNaN(nb)) {
1365
+ return Number.isNaN(na) ? 1 : -1;
1366
+ } else if (ia[i] !== ib[i]) {
1367
+ return ia[i] > ib[i] ? 1 : -1;
1368
+ }
1369
+ }
1341
1370
  return 0;
1342
1371
  }
1343
1372
 
@@ -1628,6 +1657,8 @@ async function installShellAlias(egregoreDir, ui) {
1628
1657
 
1629
1658
  module.exports = {
1630
1659
  install,
1660
+ launcherVersion,
1661
+ compareVersions,
1631
1662
  ghApi,
1632
1663
  acceptPendingInvitations,
1633
1664
  registerInstance,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.21.0-runtime-mvp.2",
3
+ "version": "0.21.0-runtime-mvp.4",
4
4
  "description": "Set up Egregore for your team in one command — installs the egregore launcher and your choice of Claude Code, Codex, or Pi.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -353,8 +353,13 @@ class QmdLocalRetriever:
353
353
  and counts["documents"] > 0
354
354
  and self._index_state_usable()
355
355
  )
356
+ # Semantic queries embed the query text at request time, so the model
357
+ # must be present even when every stored vector is current. Stored
358
+ # vectors without the model is a repair state, never "ready".
359
+ model_available = self._embedding_model_available()
356
360
  semantic_ready = bool(
357
361
  bm25_ready
362
+ and model_available
358
363
  and counts["vectors"] > 0
359
364
  and counts["pending"] == 0
360
365
  and self._semantic_state_current()
@@ -362,9 +367,15 @@ class QmdLocalRetriever:
362
367
  )
363
368
  embedding_state = self._embedding_state(status, semantic_ready=semantic_ready)
364
369
  if embedding_state == "missing_model":
365
- warnings.append(
366
- f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
367
- )
370
+ if counts["vectors"] > 0:
371
+ warnings.append(
372
+ f"{EMBEDDING_MODEL} is missing while stored vectors are intact; "
373
+ "semantic queries are unavailable until repair — run: bin/search.sh reindex --embed"
374
+ )
375
+ else:
376
+ warnings.append(
377
+ f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
378
+ )
368
379
 
369
380
  runtime = self._owned_runtime()
370
381
  if not canonical_ready or not qmd_available or not status_ok:
@@ -1568,10 +1579,13 @@ class QmdLocalRetriever:
1568
1579
  def _embedding_state(self, status: str, *, semantic_ready: bool = False) -> str:
1569
1580
  if self._live_lock(self._embed_lock_path()):
1570
1581
  return "running"
1571
- if semantic_ready:
1572
- return "ready"
1582
+ # Model presence outranks stored-vector completeness: query-time
1583
+ # embedding needs the model, so a complete build with a missing model
1584
+ # is a repair state, not "ready".
1573
1585
  if not self._embedding_model_available():
1574
1586
  return "missing_model"
1587
+ if semantic_ready:
1588
+ return "ready"
1575
1589
  counts = self._status_counts(status)
1576
1590
  if counts["pending"] > 0 or counts["documents"] > 0:
1577
1591
  return "pending"
@@ -236,6 +236,16 @@ def _embeddings_item(health: RetrieverHealth) -> ReadinessItem:
236
236
  key="embeddings", label=label, stage=STAGE_BUILDING, detail=detail
237
237
  )
238
238
  if state == "missing_model":
239
+ if health.semantic_source_revision:
240
+ return ReadinessItem(
241
+ key="embeddings",
242
+ label=label,
243
+ stage=STAGE_UNAVAILABLE,
244
+ detail=(
245
+ "embedding model missing — stored vectors intact · "
246
+ "repair: bin/search.sh reindex --embed"
247
+ ),
248
+ )
239
249
  return ReadinessItem(
240
250
  key="embeddings",
241
251
  label=label,
@@ -254,6 +264,16 @@ def _semantic_item(health: RetrieverHealth) -> ReadinessItem:
254
264
  label = "Semantic build"
255
265
  source = _short_revision(health.source_revision)
256
266
  build = _short_revision(health.semantic_source_revision)
267
+ if health.embedding_state == "missing_model":
268
+ return ReadinessItem(
269
+ key="semantic",
270
+ label=label,
271
+ stage=STAGE_UNAVAILABLE,
272
+ detail=(
273
+ "embedding model missing — semantic queries unavailable · "
274
+ "repair: bin/search.sh reindex --embed"
275
+ ),
276
+ )
257
277
  if health.semantic_ready:
258
278
  return ReadinessItem(
259
279
  key="semantic",
@@ -282,6 +302,16 @@ def _search_item(health: RetrieverHealth) -> ReadinessItem:
282
302
  return ReadinessItem(
283
303
  key="search", label=label, stage=STAGE_READY, detail="hybrid current"
284
304
  )
305
+ if health.bm25_ready and health.embedding_state == "missing_model":
306
+ return ReadinessItem(
307
+ key="search",
308
+ label=label,
309
+ stage=STAGE_READY,
310
+ detail=(
311
+ "lexical only — embedding model missing · "
312
+ "repair: bin/search.sh reindex --embed"
313
+ ),
314
+ )
285
315
  if health.bm25_ready and health.semantic_source_revision:
286
316
  return ReadinessItem(
287
317
  key="search",
@@ -353,8 +353,13 @@ class QmdLocalRetriever:
353
353
  and counts["documents"] > 0
354
354
  and self._index_state_usable()
355
355
  )
356
+ # Semantic queries embed the query text at request time, so the model
357
+ # must be present even when every stored vector is current. Stored
358
+ # vectors without the model is a repair state, never "ready".
359
+ model_available = self._embedding_model_available()
356
360
  semantic_ready = bool(
357
361
  bm25_ready
362
+ and model_available
358
363
  and counts["vectors"] > 0
359
364
  and counts["pending"] == 0
360
365
  and self._semantic_state_current()
@@ -362,9 +367,15 @@ class QmdLocalRetriever:
362
367
  )
363
368
  embedding_state = self._embedding_state(status, semantic_ready=semantic_ready)
364
369
  if embedding_state == "missing_model":
365
- warnings.append(
366
- f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
367
- )
370
+ if counts["vectors"] > 0:
371
+ warnings.append(
372
+ f"{EMBEDDING_MODEL} is missing while stored vectors are intact; "
373
+ "semantic queries are unavailable until repair — run: bin/search.sh reindex --embed"
374
+ )
375
+ else:
376
+ warnings.append(
377
+ f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
378
+ )
368
379
 
369
380
  runtime = self._owned_runtime()
370
381
  if not canonical_ready or not qmd_available or not status_ok:
@@ -1568,10 +1579,13 @@ class QmdLocalRetriever:
1568
1579
  def _embedding_state(self, status: str, *, semantic_ready: bool = False) -> str:
1569
1580
  if self._live_lock(self._embed_lock_path()):
1570
1581
  return "running"
1571
- if semantic_ready:
1572
- return "ready"
1582
+ # Model presence outranks stored-vector completeness: query-time
1583
+ # embedding needs the model, so a complete build with a missing model
1584
+ # is a repair state, not "ready".
1573
1585
  if not self._embedding_model_available():
1574
1586
  return "missing_model"
1587
+ if semantic_ready:
1588
+ return "ready"
1575
1589
  counts = self._status_counts(status)
1576
1590
  if counts["pending"] > 0 or counts["documents"] > 0:
1577
1591
  return "pending"
@@ -236,6 +236,16 @@ def _embeddings_item(health: RetrieverHealth) -> ReadinessItem:
236
236
  key="embeddings", label=label, stage=STAGE_BUILDING, detail=detail
237
237
  )
238
238
  if state == "missing_model":
239
+ if health.semantic_source_revision:
240
+ return ReadinessItem(
241
+ key="embeddings",
242
+ label=label,
243
+ stage=STAGE_UNAVAILABLE,
244
+ detail=(
245
+ "embedding model missing — stored vectors intact · "
246
+ "repair: bin/search.sh reindex --embed"
247
+ ),
248
+ )
239
249
  return ReadinessItem(
240
250
  key="embeddings",
241
251
  label=label,
@@ -254,6 +264,16 @@ def _semantic_item(health: RetrieverHealth) -> ReadinessItem:
254
264
  label = "Semantic build"
255
265
  source = _short_revision(health.source_revision)
256
266
  build = _short_revision(health.semantic_source_revision)
267
+ if health.embedding_state == "missing_model":
268
+ return ReadinessItem(
269
+ key="semantic",
270
+ label=label,
271
+ stage=STAGE_UNAVAILABLE,
272
+ detail=(
273
+ "embedding model missing — semantic queries unavailable · "
274
+ "repair: bin/search.sh reindex --embed"
275
+ ),
276
+ )
257
277
  if health.semantic_ready:
258
278
  return ReadinessItem(
259
279
  key="semantic",
@@ -282,6 +302,16 @@ def _search_item(health: RetrieverHealth) -> ReadinessItem:
282
302
  return ReadinessItem(
283
303
  key="search", label=label, stage=STAGE_READY, detail="hybrid current"
284
304
  )
305
+ if health.bm25_ready and health.embedding_state == "missing_model":
306
+ return ReadinessItem(
307
+ key="search",
308
+ label=label,
309
+ stage=STAGE_READY,
310
+ detail=(
311
+ "lexical only — embedding model missing · "
312
+ "repair: bin/search.sh reindex --embed"
313
+ ),
314
+ )
285
315
  if health.bm25_ready and health.semantic_source_revision:
286
316
  return ReadinessItem(
287
317
  key="search",
@@ -353,8 +353,13 @@ class QmdLocalRetriever:
353
353
  and counts["documents"] > 0
354
354
  and self._index_state_usable()
355
355
  )
356
+ # Semantic queries embed the query text at request time, so the model
357
+ # must be present even when every stored vector is current. Stored
358
+ # vectors without the model is a repair state, never "ready".
359
+ model_available = self._embedding_model_available()
356
360
  semantic_ready = bool(
357
361
  bm25_ready
362
+ and model_available
358
363
  and counts["vectors"] > 0
359
364
  and counts["pending"] == 0
360
365
  and self._semantic_state_current()
@@ -362,9 +367,15 @@ class QmdLocalRetriever:
362
367
  )
363
368
  embedding_state = self._embedding_state(status, semantic_ready=semantic_ready)
364
369
  if embedding_state == "missing_model":
365
- warnings.append(
366
- f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
367
- )
370
+ if counts["vectors"] > 0:
371
+ warnings.append(
372
+ f"{EMBEDDING_MODEL} is missing while stored vectors are intact; "
373
+ "semantic queries are unavailable until repair — run: bin/search.sh reindex --embed"
374
+ )
375
+ else:
376
+ warnings.append(
377
+ f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
378
+ )
368
379
 
369
380
  runtime = self._owned_runtime()
370
381
  if not canonical_ready or not qmd_available or not status_ok:
@@ -1568,10 +1579,13 @@ class QmdLocalRetriever:
1568
1579
  def _embedding_state(self, status: str, *, semantic_ready: bool = False) -> str:
1569
1580
  if self._live_lock(self._embed_lock_path()):
1570
1581
  return "running"
1571
- if semantic_ready:
1572
- return "ready"
1582
+ # Model presence outranks stored-vector completeness: query-time
1583
+ # embedding needs the model, so a complete build with a missing model
1584
+ # is a repair state, not "ready".
1573
1585
  if not self._embedding_model_available():
1574
1586
  return "missing_model"
1587
+ if semantic_ready:
1588
+ return "ready"
1575
1589
  counts = self._status_counts(status)
1576
1590
  if counts["pending"] > 0 or counts["documents"] > 0:
1577
1591
  return "pending"
@@ -236,6 +236,16 @@ def _embeddings_item(health: RetrieverHealth) -> ReadinessItem:
236
236
  key="embeddings", label=label, stage=STAGE_BUILDING, detail=detail
237
237
  )
238
238
  if state == "missing_model":
239
+ if health.semantic_source_revision:
240
+ return ReadinessItem(
241
+ key="embeddings",
242
+ label=label,
243
+ stage=STAGE_UNAVAILABLE,
244
+ detail=(
245
+ "embedding model missing — stored vectors intact · "
246
+ "repair: bin/search.sh reindex --embed"
247
+ ),
248
+ )
239
249
  return ReadinessItem(
240
250
  key="embeddings",
241
251
  label=label,
@@ -254,6 +264,16 @@ def _semantic_item(health: RetrieverHealth) -> ReadinessItem:
254
264
  label = "Semantic build"
255
265
  source = _short_revision(health.source_revision)
256
266
  build = _short_revision(health.semantic_source_revision)
267
+ if health.embedding_state == "missing_model":
268
+ return ReadinessItem(
269
+ key="semantic",
270
+ label=label,
271
+ stage=STAGE_UNAVAILABLE,
272
+ detail=(
273
+ "embedding model missing — semantic queries unavailable · "
274
+ "repair: bin/search.sh reindex --embed"
275
+ ),
276
+ )
257
277
  if health.semantic_ready:
258
278
  return ReadinessItem(
259
279
  key="semantic",
@@ -282,6 +302,16 @@ def _search_item(health: RetrieverHealth) -> ReadinessItem:
282
302
  return ReadinessItem(
283
303
  key="search", label=label, stage=STAGE_READY, detail="hybrid current"
284
304
  )
305
+ if health.bm25_ready and health.embedding_state == "missing_model":
306
+ return ReadinessItem(
307
+ key="search",
308
+ label=label,
309
+ stage=STAGE_READY,
310
+ detail=(
311
+ "lexical only — embedding model missing · "
312
+ "repair: bin/search.sh reindex --embed"
313
+ ),
314
+ )
285
315
  if health.bm25_ready and health.semantic_source_revision:
286
316
  return ReadinessItem(
287
317
  key="search",
@@ -353,8 +353,13 @@ class QmdLocalRetriever:
353
353
  and counts["documents"] > 0
354
354
  and self._index_state_usable()
355
355
  )
356
+ # Semantic queries embed the query text at request time, so the model
357
+ # must be present even when every stored vector is current. Stored
358
+ # vectors without the model is a repair state, never "ready".
359
+ model_available = self._embedding_model_available()
356
360
  semantic_ready = bool(
357
361
  bm25_ready
362
+ and model_available
358
363
  and counts["vectors"] > 0
359
364
  and counts["pending"] == 0
360
365
  and self._semantic_state_current()
@@ -362,9 +367,15 @@ class QmdLocalRetriever:
362
367
  )
363
368
  embedding_state = self._embedding_state(status, semantic_ready=semantic_ready)
364
369
  if embedding_state == "missing_model":
365
- warnings.append(
366
- f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
367
- )
370
+ if counts["vectors"] > 0:
371
+ warnings.append(
372
+ f"{EMBEDDING_MODEL} is missing while stored vectors are intact; "
373
+ "semantic queries are unavailable until repair — run: bin/search.sh reindex --embed"
374
+ )
375
+ else:
376
+ warnings.append(
377
+ f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
378
+ )
368
379
 
369
380
  runtime = self._owned_runtime()
370
381
  if not canonical_ready or not qmd_available or not status_ok:
@@ -1568,10 +1579,13 @@ class QmdLocalRetriever:
1568
1579
  def _embedding_state(self, status: str, *, semantic_ready: bool = False) -> str:
1569
1580
  if self._live_lock(self._embed_lock_path()):
1570
1581
  return "running"
1571
- if semantic_ready:
1572
- return "ready"
1582
+ # Model presence outranks stored-vector completeness: query-time
1583
+ # embedding needs the model, so a complete build with a missing model
1584
+ # is a repair state, not "ready".
1573
1585
  if not self._embedding_model_available():
1574
1586
  return "missing_model"
1587
+ if semantic_ready:
1588
+ return "ready"
1575
1589
  counts = self._status_counts(status)
1576
1590
  if counts["pending"] > 0 or counts["documents"] > 0:
1577
1591
  return "pending"
@@ -236,6 +236,16 @@ def _embeddings_item(health: RetrieverHealth) -> ReadinessItem:
236
236
  key="embeddings", label=label, stage=STAGE_BUILDING, detail=detail
237
237
  )
238
238
  if state == "missing_model":
239
+ if health.semantic_source_revision:
240
+ return ReadinessItem(
241
+ key="embeddings",
242
+ label=label,
243
+ stage=STAGE_UNAVAILABLE,
244
+ detail=(
245
+ "embedding model missing — stored vectors intact · "
246
+ "repair: bin/search.sh reindex --embed"
247
+ ),
248
+ )
239
249
  return ReadinessItem(
240
250
  key="embeddings",
241
251
  label=label,
@@ -254,6 +264,16 @@ def _semantic_item(health: RetrieverHealth) -> ReadinessItem:
254
264
  label = "Semantic build"
255
265
  source = _short_revision(health.source_revision)
256
266
  build = _short_revision(health.semantic_source_revision)
267
+ if health.embedding_state == "missing_model":
268
+ return ReadinessItem(
269
+ key="semantic",
270
+ label=label,
271
+ stage=STAGE_UNAVAILABLE,
272
+ detail=(
273
+ "embedding model missing — semantic queries unavailable · "
274
+ "repair: bin/search.sh reindex --embed"
275
+ ),
276
+ )
257
277
  if health.semantic_ready:
258
278
  return ReadinessItem(
259
279
  key="semantic",
@@ -282,6 +302,16 @@ def _search_item(health: RetrieverHealth) -> ReadinessItem:
282
302
  return ReadinessItem(
283
303
  key="search", label=label, stage=STAGE_READY, detail="hybrid current"
284
304
  )
305
+ if health.bm25_ready and health.embedding_state == "missing_model":
306
+ return ReadinessItem(
307
+ key="search",
308
+ label=label,
309
+ stage=STAGE_READY,
310
+ detail=(
311
+ "lexical only — embedding model missing · "
312
+ "repair: bin/search.sh reindex --embed"
313
+ ),
314
+ )
285
315
  if health.bm25_ready and health.semantic_source_revision:
286
316
  return ReadinessItem(
287
317
  key="search",