create-egregore 0.21.0-runtime-mvp.13 → 0.21.0-runtime-mvp.15

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.13
2
+ // egregore-launcher-version: 0.21.0-runtime-mvp.15
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.13";
38
+ const VERSION = "0.21.0-runtime-mvp.15";
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");
@@ -2673,7 +2673,11 @@ function formatUpgradeLines(inst, st, { candidate = "", retrieval = null } = {})
2673
2673
  + (d.candidate_version ? ` → candidate ${d.candidate_version}` : candidate ? ` → candidate ${candidate} available` : ""));
2674
2674
  lines.push(`Status: ${UPGRADE_STATUS_LABEL[d.status] || d.status}`);
2675
2675
  if (d.candidate_digest) lines.push(`Package digest: sha256 ${String(d.candidate_digest).slice(0, 16)}…`);
2676
- if (d.failure && d.failure.message) lines.push(`Attention: ${d.failure.message}`);
2676
+ // A recorded failure only needs attention while the transaction is stuck
2677
+ // on it; once a later prepare succeeds the line would contradict "ready".
2678
+ if (d.failure && d.failure.message && (d.status === "failed" || d.status === "interrupted")) {
2679
+ lines.push(`Attention: ${d.failure.message}`);
2680
+ }
2677
2681
  if (d.rollback_version && d.status !== "none") lines.push(`Rollback target: ${d.rollback_version}`);
2678
2682
  const ts = d.team_sync;
2679
2683
  if (ts && ts.label) lines.push(`Team memory: ${ts.label}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.21.0-runtime-mvp.13",
3
+ "version": "0.21.0-runtime-mvp.15",
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": {
@@ -95,19 +95,15 @@ if [ -n "$SAVED_BRANCH" ]; then
95
95
  echo " ✓ Auto-saved uncommitted work on $SAVED_BRANCH"
96
96
  fi
97
97
 
98
- # --- Unshared org-settings drift (launcher writes not yet shared) ---
99
- if [ "${SETTINGS_ADAPTER_RESTORED:-false}" = "true" ]; then
100
- echo " ◇ restored org .claude/settings.json — a generated adapter had replaced it locally"
101
- fi
98
+ # --- Org-settings sync receipts. A settings.json heal is logged to
99
+ # .egregore/settings-drift.log, never announced; local settings changes reach
100
+ # the control plane through bin/settings.sh, so there is nothing to ask. ---
102
101
  if [ -n "${SETTINGS_SYNCED_FROM_ORG:-}" ]; then
103
102
  echo " ◇ org settings synced from the control plane (${SETTINGS_SYNCED_FROM_ORG})"
104
103
  fi
105
104
  if [ -n "${SETTINGS_POSTURE_HELD:-}" ]; then
106
105
  echo " ◇ boundary posture was set to ${SETTINGS_POSTURE_HELD} elsewhere — relaxing access needs your confirm: bash bin/settings.sh posture ${SETTINGS_POSTURE_HELD}"
107
106
  fi
108
- if [ -n "${UNSHARED_SETTINGS_KEYS:-}" ]; then
109
- echo " ◇ org settings changed on this machine (${UNSHARED_SETTINGS_KEYS}) — say \"share settings\" to make them org-wide"
110
- fi
111
107
 
112
108
  # --- Footer: health + repos + memory (tertiary) ---
113
109
  echo "$SEPARATOR"
@@ -61,7 +61,11 @@ settings_drift_heal() {
61
61
 
62
62
  if ! git diff --quiet -- .claude/settings.json 2>/dev/null; then
63
63
  if _settings_is_reduced_adapter; then
64
- git checkout -- .claude/settings.json 2>/dev/null && SETTINGS_ADAPTER_RESTORED="true"
64
+ if git checkout -- .claude/settings.json 2>/dev/null; then
65
+ SETTINGS_ADAPTER_RESTORED="true"
66
+ mkdir -p .egregore 2>/dev/null && printf '%s restored .claude/settings.json: a generated adapter had replaced the org file\n' \
67
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> .egregore/settings-drift.log 2>/dev/null || true
68
+ fi
65
69
  fi
66
70
  fi
67
71
 
@@ -32,6 +32,19 @@ _maintenance_runtime() {
32
32
  python3 -m egregore_runtime.adapters.qmd_cli "$@"
33
33
  }
34
34
 
35
+ _now_ms() {
36
+ if command -v perl >/dev/null 2>&1; then
37
+ perl -MTime::HiRes=time -e 'printf("%d\n", time() * 1000)'
38
+ else
39
+ echo $(( $(date +%s) * 1000 ))
40
+ fi
41
+ }
42
+
43
+ _elapsed_s() {
44
+ # One-decimal seconds since a _now_ms() timestamp.
45
+ awk -v ms="$(( $(_now_ms) - $1 ))" 'BEGIN { printf "%.1f", ms / 1000 }'
46
+ }
47
+
35
48
  _result_paths() {
36
49
  local line path canonical_prefix
37
50
  canonical_prefix="**file:** \`memory/"
@@ -125,14 +138,14 @@ _banner_and_emit() {
125
138
  actual="${actual%% *}"
126
139
  required="${gap#*required=}"
127
140
  required="${required%% *}"
128
- dur=$(( $(date +%s) - t0 ))
141
+ dur=$(_elapsed_s "$t0")
129
142
  printf '⌕ Egregore · searching your organization’s memory · %s · evidence gap · %s/%s sources · %ss\n\n' \
130
143
  "$label" "$actual" "$required" "$dur"
131
144
  [ "$hide_results" = 1 ] || printf '%s\n' "$out" | tail -n +2
132
145
  return 0
133
146
  fi
134
147
  hits=$(printf '%s\n' "$out" | _result_paths | wc -l | tr -d ' ')
135
- dur=$(( $(date +%s) - t0 ))
148
+ dur=$(_elapsed_s "$t0")
136
149
  product="Egregore"
137
150
  surface="searching your organization’s memory"
138
151
  if [ "$emit" = "_enrich" ] && [ "$hits" -gt 0 ]; then
@@ -223,7 +236,7 @@ cmd_query() {
223
236
  esac
224
237
 
225
238
  local t0 out emit="cat" query_rc
226
- t0=$(date +%s)
239
+ t0=$(_now_ms)
227
240
  out=$(_runtime "${args[@]}" 2>&1)
228
241
  query_rc=$?
229
242
  if [ "$query_rc" -ne 0 ]; then
@@ -235,20 +248,31 @@ cmd_query() {
235
248
  } >&2
236
249
  return "$query_rc"
237
250
  fi
238
- # Content-free query telemetry: mode, duration, result count. Never the
239
- # query text. This is what makes Runtime/QMD retrieval visible in the
240
- # telemetry event list instead of only legacy graph_query rows.
251
+ # The runtime marks a query that ran without its persistent worker (the
252
+ # pinned CLI started cold). Strip the marker from the evidence and carry
253
+ # it into the banner, the telemetry, and a one-line repair hint.
254
+ local cold=0 cold_json=false
255
+ if printf '%s\n' "$out" | grep -qx 'EGREGORE_RETRIEVAL_COLD_START'; then
256
+ cold=1
257
+ cold_json=true
258
+ out=$(printf '%s\n' "$out" | grep -vx 'EGREGORE_RETRIEVAL_COLD_START')
259
+ fi
260
+ # Content-free query telemetry: mode, duration, result count, degraded
261
+ # path. Never the query text. This is what makes Runtime/QMD retrieval
262
+ # visible in the telemetry event list instead of only legacy graph_query rows.
241
263
  local _tele_count _tele_ms
242
- _tele_ms=$(( ($(date +%s) - t0) * 1000 ))
264
+ _tele_ms=$(( $(_now_ms) - t0 ))
243
265
  _tele_count=$(printf '%s' "$out" | jq 'if type == "array" then length elif type == "object" then (.results // [] | length) else empty end' 2>/dev/null || true)
244
266
  if [[ "$_tele_count" =~ ^[0-9]+$ ]]; then
245
267
  bash "$SCRIPT_DIR/bin/telemetry.sh" emit "query" \
246
- "$(jq -n --arg mode "$label" --argjson d "$_tele_ms" --argjson c "$_tele_count" \
247
- '{mode:$mode, duration_ms:$d, count:$c}')" 2>/dev/null &
268
+ "$(jq -n --arg mode "$label" --argjson d "$_tele_ms" --argjson c "$_tele_count" --argjson cold "$cold_json" \
269
+ '{mode:$mode, duration_ms:$d, count:$c, degraded:$cold}')" 2>/dev/null &
248
270
  else
249
271
  bash "$SCRIPT_DIR/bin/telemetry.sh" emit "query" \
250
- "$(jq -n --arg mode "$label" --argjson d "$_tele_ms" '{mode:$mode, duration_ms:$d}')" 2>/dev/null &
272
+ "$(jq -n --arg mode "$label" --argjson d "$_tele_ms" --argjson cold "$cold_json" \
273
+ '{mode:$mode, duration_ms:$d, degraded:$cold}')" 2>/dev/null &
251
274
  fi
275
+ [ "$cold" = 1 ] && label="${label} · cold start"
252
276
 
253
277
  [ "$enrich" = 1 ] && emit="_enrich"
254
278
  if [ "$context_packet" = 1 ]; then
@@ -263,6 +287,9 @@ cmd_query() {
263
287
  else
264
288
  _banner_and_emit "$label" "$t0" "$emit" "$out"
265
289
  fi
290
+ if [ "$cold" = 1 ]; then
291
+ echo " ↳ the retrieval server was not running, so this query started QMD cold. Repair: bash bin/search.sh start" >&2
292
+ fi
266
293
  }
267
294
 
268
295
  cmd_reindex() {
@@ -157,6 +157,11 @@ class QmdLocalRetriever:
157
157
  self._resolved_command: tuple[str, ...] | None = None
158
158
  self._version_checked = False
159
159
  self._daemon_available = False
160
+ # Outcome of the most recent typed query: True when the owned worker
161
+ # answered, False when the pinned CLI ran cold, None when no query
162
+ # ran or persistence is disabled. Read by the harness so a slow
163
+ # retrieval is never silent.
164
+ self._last_query_used_daemon: bool | None = None
160
165
  self._source_revision_cache: str | None = None
161
166
  self._configure_isolated_environment()
162
167
  self._prefer_usable_node_runtime()
@@ -726,7 +731,10 @@ class QmdLocalRetriever:
726
731
  limit: int,
727
732
  intent: str,
728
733
  ) -> list[dict[str, Any]]:
729
- if self._persistent_enabled() and self._ensure_daemon():
734
+ self._last_query_used_daemon = None
735
+ if not self._persistent_enabled():
736
+ daemon_error = "daemon unavailable"
737
+ elif self._ensure_daemon():
730
738
  payload = {
731
739
  "searches": searches,
732
740
  "collections": [self.collection],
@@ -746,6 +754,7 @@ class QmdLocalRetriever:
746
754
  body = json.loads(response.read().decode("utf-8"))
747
755
  results = body.get("results") if isinstance(body, dict) else None
748
756
  if isinstance(results, list):
757
+ self._last_query_used_daemon = True
749
758
  return [item for item in results if isinstance(item, dict)]
750
759
  except (OSError, ValueError, urllib.error.URLError) as exc:
751
760
  self._daemon_available = False
@@ -754,8 +763,13 @@ class QmdLocalRetriever:
754
763
  daemon_error = str(exc)
755
764
  else:
756
765
  daemon_error = "daemon returned an invalid result payload"
766
+ self._last_query_used_daemon = False
757
767
  else:
768
+ # The owned worker could not be started or reused. The pinned CLI
769
+ # answers cold; the harness surfaces that instead of a silent
770
+ # multi-second query.
758
771
  daemon_error = "daemon unavailable"
772
+ self._last_query_used_daemon = False
759
773
 
760
774
  query_document = "\n".join(
761
775
  f"{item['type']}: {self._single_line(item['query'])}" for item in searches
@@ -1009,6 +1023,13 @@ class QmdLocalRetriever:
1009
1023
  def _persistent_enabled(self) -> bool:
1010
1024
  return self.environment.get("EGREGORE_QMD_PERSISTENT", "1") != "0"
1011
1025
 
1026
+ @property
1027
+ def last_query_used_daemon(self) -> bool | None:
1028
+ """True when the last typed query answered through the owned worker,
1029
+ False when it fell back to a cold CLI run, None when no query ran or
1030
+ the persistent worker is disabled for this instance."""
1031
+ return self._last_query_used_daemon
1032
+
1012
1033
  def _ensure_daemon(self) -> bool:
1013
1034
  if not self._persistent_enabled():
1014
1035
  return False
@@ -1153,15 +1174,30 @@ class QmdLocalRetriever:
1153
1174
 
1154
1175
  @staticmethod
1155
1176
  def _port_available(port: int) -> bool:
1177
+ """A port is free only when both loopback families can bind it.
1178
+
1179
+ The worker binds "localhost", which resolves to ::1 as well as
1180
+ 127.0.0.1. Probing IPv4 alone reported a port free while a foreign
1181
+ server held [::1] on it; the owned worker then died on bind and every
1182
+ query ran the CLI cold.
1183
+ """
1156
1184
  if port == 8181:
1157
1185
  return False
1158
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
1159
- probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1186
+ for family, address in (
1187
+ (socket.AF_INET, "127.0.0.1"),
1188
+ (socket.AF_INET6, "::1"),
1189
+ ):
1160
1190
  try:
1161
- probe.bind(("127.0.0.1", port))
1162
- return True
1191
+ probe = socket.socket(family, socket.SOCK_STREAM)
1163
1192
  except OSError:
1164
- return False
1193
+ continue # family not available on this host
1194
+ with probe:
1195
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1196
+ try:
1197
+ probe.bind((address, port))
1198
+ except OSError:
1199
+ return False
1200
+ return True
1165
1201
 
1166
1202
  def _allocate_port(self) -> int:
1167
1203
  for _ in range(20):
@@ -369,6 +369,12 @@ def _query(args: argparse.Namespace) -> int:
369
369
  )
370
370
  else:
371
371
  print("No results found.")
372
+ retriever = getattr(runtime, "retriever", None)
373
+ if not args.json and getattr(retriever, "last_query_used_daemon", None) is False:
374
+ # The owned QMD worker was unavailable and the pinned CLI answered
375
+ # cold. bin/search.sh turns this marker into a visible "cold start"
376
+ # note so a slow query is never silent.
377
+ print("EGREGORE_RETRIEVAL_COLD_START")
372
378
  return 0
373
379
 
374
380
 
@@ -53,6 +53,40 @@ BUNDLE_TREES = ("claude", "codex", "pi", "prime")
53
53
  # wins and duplicates are skipped.
54
54
  APPLY_TREES = ("claude", "codex", "pi", "prime")
55
55
  APPLY_TREE = APPLY_TREES[0]
56
+ # Bundle metadata at the root of each adapter tree; never instance content.
57
+ BUNDLE_MANIFEST = "manifest.json"
58
+ # The harness settings adapter the bundle generates; an instance's richer
59
+ # committed copy is preserved on activation.
60
+ SETTINGS_ADAPTER = ".claude/settings.json"
61
+
62
+
63
+ def _settings_carries_more(existing: Path, candidate: Path) -> bool:
64
+ """True when the instance's settings file has hook events or permission
65
+ allows the candidate's generated adapter lacks. An unreadable candidate
66
+ keeps the existing file; an unreadable or missing existing file does not."""
67
+ if not existing.is_file():
68
+ return False
69
+ try:
70
+ current = json.loads(existing.read_text(encoding="utf-8"))
71
+ except (OSError, ValueError):
72
+ return False
73
+ try:
74
+ incoming = json.loads(candidate.read_text(encoding="utf-8"))
75
+ except (OSError, ValueError):
76
+ return True
77
+ if not isinstance(current, dict) or not isinstance(incoming, dict):
78
+ return False
79
+
80
+ def hooks(doc: dict) -> set[str]:
81
+ value = doc.get("hooks")
82
+ return set(value.keys()) if isinstance(value, dict) else set()
83
+
84
+ def allows(doc: dict) -> set[str]:
85
+ perms = doc.get("permissions")
86
+ value = perms.get("allow") if isinstance(perms, dict) else None
87
+ return {str(v) for v in value} if isinstance(value, list) else set()
88
+
89
+ return bool(hooks(current) - hooks(incoming)) or bool(allows(current) - allows(incoming))
56
90
 
57
91
  STATUS_PENDING = "pending"
58
92
  STATUS_PREPARING = "preparing"
@@ -744,6 +778,22 @@ class UpgradeEngine:
744
778
  continue
745
779
  relative = str(source_file.relative_to(tree))
746
780
  selected.setdefault(relative, source_file)
781
+ # The bundle's own manifest describes the package, not the instance;
782
+ # nothing in an instance reads it and it only shows up as an untracked
783
+ # file in the instance's repository.
784
+ selected.pop(BUNDLE_MANIFEST, None)
785
+ # The instance's own settings adapter may carry hook events or
786
+ # permission allows the generated public-safe adapter lacks (an org's
787
+ # committed .claude/settings.json). Activation never reduces it — the
788
+ # same rule the installer applies. A stale generated adapter (equal or
789
+ # a subset) still upgrades.
790
+ preserved: list[str] = []
791
+ candidate_settings = selected.get(SETTINGS_ADAPTER)
792
+ if candidate_settings is not None and _settings_carries_more(
793
+ instance / SETTINGS_ADAPTER, candidate_settings
794
+ ):
795
+ selected.pop(SETTINGS_ADAPTER, None)
796
+ preserved.append(SETTINGS_ADAPTER)
747
797
  # Journal opens the transaction BEFORE the first byte moves. From here
748
798
  # until the caller clears it, a crash is recoverable from the backup.
749
799
  self.store.write_apply_journal({
@@ -794,7 +844,12 @@ class UpgradeEngine:
794
844
  (self.store.root / "rollback-files.tar.gz").unlink(missing_ok=True)
795
845
  (self.store.root / "rollback-manifest.json").unlink(missing_ok=True)
796
846
  raise
797
- return {"applied_files": len(selected), "replaced": len(replaced), "added": len(added)}
847
+ return {
848
+ "applied_files": len(selected),
849
+ "replaced": len(replaced),
850
+ "added": len(added),
851
+ "preserved": preserved,
852
+ }
798
853
 
799
854
  def activate(self) -> dict[str, Any]:
800
855
  self._acquire_apply_lock()
@@ -886,7 +941,14 @@ class UpgradeEngine:
886
941
  self.store.request_cancel()
887
942
  worker = state.get("worker") or {}
888
943
  pid = worker.get("pid")
889
- if state.get("status") == STATUS_PREPARING and not (pid and _pid_alive(pid)):
944
+ status = state.get("status")
945
+ if status in (STATUS_PENDING, STATUS_READY, STATUS_FAILED):
946
+ # Nothing is running: release the staged candidate outright so a
947
+ # newer one can be staged. A ready or failed candidate used to be
948
+ # unreleasable — cancel ignored it and init refused "already staged".
949
+ state["status"] = STATUS_CANCELLED
950
+ self.store.write(state)
951
+ elif status == STATUS_PREPARING and not (pid and _pid_alive(pid)):
890
952
  # No live worker will observe the flag — record the cancel now.
891
953
  state["status"] = STATUS_CANCELLED
892
954
  self.store.write(state)
@@ -95,19 +95,15 @@ if [ -n "$SAVED_BRANCH" ]; then
95
95
  echo " ✓ Auto-saved uncommitted work on $SAVED_BRANCH"
96
96
  fi
97
97
 
98
- # --- Unshared org-settings drift (launcher writes not yet shared) ---
99
- if [ "${SETTINGS_ADAPTER_RESTORED:-false}" = "true" ]; then
100
- echo " ◇ restored org .claude/settings.json — a generated adapter had replaced it locally"
101
- fi
98
+ # --- Org-settings sync receipts. A settings.json heal is logged to
99
+ # .egregore/settings-drift.log, never announced; local settings changes reach
100
+ # the control plane through bin/settings.sh, so there is nothing to ask. ---
102
101
  if [ -n "${SETTINGS_SYNCED_FROM_ORG:-}" ]; then
103
102
  echo " ◇ org settings synced from the control plane (${SETTINGS_SYNCED_FROM_ORG})"
104
103
  fi
105
104
  if [ -n "${SETTINGS_POSTURE_HELD:-}" ]; then
106
105
  echo " ◇ boundary posture was set to ${SETTINGS_POSTURE_HELD} elsewhere — relaxing access needs your confirm: bash bin/settings.sh posture ${SETTINGS_POSTURE_HELD}"
107
106
  fi
108
- if [ -n "${UNSHARED_SETTINGS_KEYS:-}" ]; then
109
- echo " ◇ org settings changed on this machine (${UNSHARED_SETTINGS_KEYS}) — say \"share settings\" to make them org-wide"
110
- fi
111
107
 
112
108
  # --- Footer: health + repos + memory (tertiary) ---
113
109
  echo "$SEPARATOR"
@@ -61,7 +61,11 @@ settings_drift_heal() {
61
61
 
62
62
  if ! git diff --quiet -- .claude/settings.json 2>/dev/null; then
63
63
  if _settings_is_reduced_adapter; then
64
- git checkout -- .claude/settings.json 2>/dev/null && SETTINGS_ADAPTER_RESTORED="true"
64
+ if git checkout -- .claude/settings.json 2>/dev/null; then
65
+ SETTINGS_ADAPTER_RESTORED="true"
66
+ mkdir -p .egregore 2>/dev/null && printf '%s restored .claude/settings.json: a generated adapter had replaced the org file\n' \
67
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> .egregore/settings-drift.log 2>/dev/null || true
68
+ fi
65
69
  fi
66
70
  fi
67
71
 
@@ -32,6 +32,19 @@ _maintenance_runtime() {
32
32
  python3 -m egregore_runtime.adapters.qmd_cli "$@"
33
33
  }
34
34
 
35
+ _now_ms() {
36
+ if command -v perl >/dev/null 2>&1; then
37
+ perl -MTime::HiRes=time -e 'printf("%d\n", time() * 1000)'
38
+ else
39
+ echo $(( $(date +%s) * 1000 ))
40
+ fi
41
+ }
42
+
43
+ _elapsed_s() {
44
+ # One-decimal seconds since a _now_ms() timestamp.
45
+ awk -v ms="$(( $(_now_ms) - $1 ))" 'BEGIN { printf "%.1f", ms / 1000 }'
46
+ }
47
+
35
48
  _result_paths() {
36
49
  local line path canonical_prefix
37
50
  canonical_prefix="**file:** \`memory/"
@@ -125,14 +138,14 @@ _banner_and_emit() {
125
138
  actual="${actual%% *}"
126
139
  required="${gap#*required=}"
127
140
  required="${required%% *}"
128
- dur=$(( $(date +%s) - t0 ))
141
+ dur=$(_elapsed_s "$t0")
129
142
  printf '⌕ Egregore · searching your organization’s memory · %s · evidence gap · %s/%s sources · %ss\n\n' \
130
143
  "$label" "$actual" "$required" "$dur"
131
144
  [ "$hide_results" = 1 ] || printf '%s\n' "$out" | tail -n +2
132
145
  return 0
133
146
  fi
134
147
  hits=$(printf '%s\n' "$out" | _result_paths | wc -l | tr -d ' ')
135
- dur=$(( $(date +%s) - t0 ))
148
+ dur=$(_elapsed_s "$t0")
136
149
  product="Egregore"
137
150
  surface="searching your organization’s memory"
138
151
  if [ "$emit" = "_enrich" ] && [ "$hits" -gt 0 ]; then
@@ -223,7 +236,7 @@ cmd_query() {
223
236
  esac
224
237
 
225
238
  local t0 out emit="cat" query_rc
226
- t0=$(date +%s)
239
+ t0=$(_now_ms)
227
240
  out=$(_runtime "${args[@]}" 2>&1)
228
241
  query_rc=$?
229
242
  if [ "$query_rc" -ne 0 ]; then
@@ -235,20 +248,31 @@ cmd_query() {
235
248
  } >&2
236
249
  return "$query_rc"
237
250
  fi
238
- # Content-free query telemetry: mode, duration, result count. Never the
239
- # query text. This is what makes Runtime/QMD retrieval visible in the
240
- # telemetry event list instead of only legacy graph_query rows.
251
+ # The runtime marks a query that ran without its persistent worker (the
252
+ # pinned CLI started cold). Strip the marker from the evidence and carry
253
+ # it into the banner, the telemetry, and a one-line repair hint.
254
+ local cold=0 cold_json=false
255
+ if printf '%s\n' "$out" | grep -qx 'EGREGORE_RETRIEVAL_COLD_START'; then
256
+ cold=1
257
+ cold_json=true
258
+ out=$(printf '%s\n' "$out" | grep -vx 'EGREGORE_RETRIEVAL_COLD_START')
259
+ fi
260
+ # Content-free query telemetry: mode, duration, result count, degraded
261
+ # path. Never the query text. This is what makes Runtime/QMD retrieval
262
+ # visible in the telemetry event list instead of only legacy graph_query rows.
241
263
  local _tele_count _tele_ms
242
- _tele_ms=$(( ($(date +%s) - t0) * 1000 ))
264
+ _tele_ms=$(( $(_now_ms) - t0 ))
243
265
  _tele_count=$(printf '%s' "$out" | jq 'if type == "array" then length elif type == "object" then (.results // [] | length) else empty end' 2>/dev/null || true)
244
266
  if [[ "$_tele_count" =~ ^[0-9]+$ ]]; then
245
267
  bash "$SCRIPT_DIR/bin/telemetry.sh" emit "query" \
246
- "$(jq -n --arg mode "$label" --argjson d "$_tele_ms" --argjson c "$_tele_count" \
247
- '{mode:$mode, duration_ms:$d, count:$c}')" 2>/dev/null &
268
+ "$(jq -n --arg mode "$label" --argjson d "$_tele_ms" --argjson c "$_tele_count" --argjson cold "$cold_json" \
269
+ '{mode:$mode, duration_ms:$d, count:$c, degraded:$cold}')" 2>/dev/null &
248
270
  else
249
271
  bash "$SCRIPT_DIR/bin/telemetry.sh" emit "query" \
250
- "$(jq -n --arg mode "$label" --argjson d "$_tele_ms" '{mode:$mode, duration_ms:$d}')" 2>/dev/null &
272
+ "$(jq -n --arg mode "$label" --argjson d "$_tele_ms" --argjson cold "$cold_json" \
273
+ '{mode:$mode, duration_ms:$d, degraded:$cold}')" 2>/dev/null &
251
274
  fi
275
+ [ "$cold" = 1 ] && label="${label} · cold start"
252
276
 
253
277
  [ "$enrich" = 1 ] && emit="_enrich"
254
278
  if [ "$context_packet" = 1 ]; then
@@ -263,6 +287,9 @@ cmd_query() {
263
287
  else
264
288
  _banner_and_emit "$label" "$t0" "$emit" "$out"
265
289
  fi
290
+ if [ "$cold" = 1 ]; then
291
+ echo " ↳ the retrieval server was not running, so this query started QMD cold. Repair: bash bin/search.sh start" >&2
292
+ fi
266
293
  }
267
294
 
268
295
  cmd_reindex() {
@@ -157,6 +157,11 @@ class QmdLocalRetriever:
157
157
  self._resolved_command: tuple[str, ...] | None = None
158
158
  self._version_checked = False
159
159
  self._daemon_available = False
160
+ # Outcome of the most recent typed query: True when the owned worker
161
+ # answered, False when the pinned CLI ran cold, None when no query
162
+ # ran or persistence is disabled. Read by the harness so a slow
163
+ # retrieval is never silent.
164
+ self._last_query_used_daemon: bool | None = None
160
165
  self._source_revision_cache: str | None = None
161
166
  self._configure_isolated_environment()
162
167
  self._prefer_usable_node_runtime()
@@ -726,7 +731,10 @@ class QmdLocalRetriever:
726
731
  limit: int,
727
732
  intent: str,
728
733
  ) -> list[dict[str, Any]]:
729
- if self._persistent_enabled() and self._ensure_daemon():
734
+ self._last_query_used_daemon = None
735
+ if not self._persistent_enabled():
736
+ daemon_error = "daemon unavailable"
737
+ elif self._ensure_daemon():
730
738
  payload = {
731
739
  "searches": searches,
732
740
  "collections": [self.collection],
@@ -746,6 +754,7 @@ class QmdLocalRetriever:
746
754
  body = json.loads(response.read().decode("utf-8"))
747
755
  results = body.get("results") if isinstance(body, dict) else None
748
756
  if isinstance(results, list):
757
+ self._last_query_used_daemon = True
749
758
  return [item for item in results if isinstance(item, dict)]
750
759
  except (OSError, ValueError, urllib.error.URLError) as exc:
751
760
  self._daemon_available = False
@@ -754,8 +763,13 @@ class QmdLocalRetriever:
754
763
  daemon_error = str(exc)
755
764
  else:
756
765
  daemon_error = "daemon returned an invalid result payload"
766
+ self._last_query_used_daemon = False
757
767
  else:
768
+ # The owned worker could not be started or reused. The pinned CLI
769
+ # answers cold; the harness surfaces that instead of a silent
770
+ # multi-second query.
758
771
  daemon_error = "daemon unavailable"
772
+ self._last_query_used_daemon = False
759
773
 
760
774
  query_document = "\n".join(
761
775
  f"{item['type']}: {self._single_line(item['query'])}" for item in searches
@@ -1009,6 +1023,13 @@ class QmdLocalRetriever:
1009
1023
  def _persistent_enabled(self) -> bool:
1010
1024
  return self.environment.get("EGREGORE_QMD_PERSISTENT", "1") != "0"
1011
1025
 
1026
+ @property
1027
+ def last_query_used_daemon(self) -> bool | None:
1028
+ """True when the last typed query answered through the owned worker,
1029
+ False when it fell back to a cold CLI run, None when no query ran or
1030
+ the persistent worker is disabled for this instance."""
1031
+ return self._last_query_used_daemon
1032
+
1012
1033
  def _ensure_daemon(self) -> bool:
1013
1034
  if not self._persistent_enabled():
1014
1035
  return False
@@ -1153,15 +1174,30 @@ class QmdLocalRetriever:
1153
1174
 
1154
1175
  @staticmethod
1155
1176
  def _port_available(port: int) -> bool:
1177
+ """A port is free only when both loopback families can bind it.
1178
+
1179
+ The worker binds "localhost", which resolves to ::1 as well as
1180
+ 127.0.0.1. Probing IPv4 alone reported a port free while a foreign
1181
+ server held [::1] on it; the owned worker then died on bind and every
1182
+ query ran the CLI cold.
1183
+ """
1156
1184
  if port == 8181:
1157
1185
  return False
1158
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
1159
- probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1186
+ for family, address in (
1187
+ (socket.AF_INET, "127.0.0.1"),
1188
+ (socket.AF_INET6, "::1"),
1189
+ ):
1160
1190
  try:
1161
- probe.bind(("127.0.0.1", port))
1162
- return True
1191
+ probe = socket.socket(family, socket.SOCK_STREAM)
1163
1192
  except OSError:
1164
- return False
1193
+ continue # family not available on this host
1194
+ with probe:
1195
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1196
+ try:
1197
+ probe.bind((address, port))
1198
+ except OSError:
1199
+ return False
1200
+ return True
1165
1201
 
1166
1202
  def _allocate_port(self) -> int:
1167
1203
  for _ in range(20):
@@ -369,6 +369,12 @@ def _query(args: argparse.Namespace) -> int:
369
369
  )
370
370
  else:
371
371
  print("No results found.")
372
+ retriever = getattr(runtime, "retriever", None)
373
+ if not args.json and getattr(retriever, "last_query_used_daemon", None) is False:
374
+ # The owned QMD worker was unavailable and the pinned CLI answered
375
+ # cold. bin/search.sh turns this marker into a visible "cold start"
376
+ # note so a slow query is never silent.
377
+ print("EGREGORE_RETRIEVAL_COLD_START")
372
378
  return 0
373
379
 
374
380