create-egregore 0.21.0-runtime-mvp.9 → 0.21.0-runtime-mvp.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/assets/egregore-launcher.js +3 -3
  2. package/lib/claude-runtime.js +23 -1
  3. package/package.json +1 -1
  4. package/runtime/claude/bin/lib/git-sync.sh +11 -1
  5. package/runtime/claude/bin/lib/greeting.sh +8 -0
  6. package/runtime/claude/bin/lib/settings-drift.sh +116 -0
  7. package/runtime/claude/bin/search.sh +15 -0
  8. package/runtime/claude/bin/settings.sh +3 -3
  9. package/runtime/claude/bin/telemetry.sh +36 -23
  10. package/runtime/claude/egregore_runtime/issue_cli.py +3 -3
  11. package/runtime/claude/manifest.json +1 -0
  12. package/runtime/codex/bin/lib/git-sync.sh +11 -1
  13. package/runtime/codex/bin/lib/greeting.sh +8 -0
  14. package/runtime/codex/bin/lib/settings-drift.sh +116 -0
  15. package/runtime/codex/bin/search.sh +15 -0
  16. package/runtime/codex/bin/settings.sh +3 -3
  17. package/runtime/codex/bin/telemetry.sh +36 -23
  18. package/runtime/codex/egregore_runtime/issue_cli.py +3 -3
  19. package/runtime/codex/manifest.json +1 -0
  20. package/runtime/pi/bin/lib/git-sync.sh +11 -1
  21. package/runtime/pi/bin/lib/greeting.sh +8 -0
  22. package/runtime/pi/bin/lib/settings-drift.sh +116 -0
  23. package/runtime/pi/bin/search.sh +15 -0
  24. package/runtime/pi/bin/settings.sh +3 -3
  25. package/runtime/pi/bin/telemetry.sh +36 -23
  26. package/runtime/pi/egregore_runtime/issue_cli.py +3 -3
  27. package/runtime/pi/manifest.json +1 -0
  28. package/runtime/prime/bin/lib/git-sync.sh +11 -1
  29. package/runtime/prime/bin/lib/greeting.sh +8 -0
  30. package/runtime/prime/bin/lib/settings-drift.sh +116 -0
  31. package/runtime/prime/bin/search.sh +15 -0
  32. package/runtime/prime/bin/settings.sh +3 -3
  33. package/runtime/prime/bin/telemetry.sh +36 -23
  34. package/runtime/prime/egregore_runtime/issue_cli.py +3 -3
  35. package/runtime/prime/manifest.json +1 -0
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // egregore-launcher-version: 0.21.0-runtime-mvp.9
2
+ // egregore-launcher-version: 0.21.0-runtime-mvp.11
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.9";
38
+ const VERSION = "0.21.0-runtime-mvp.11";
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");
@@ -4046,7 +4046,7 @@ async function browser(args = {}) {
4046
4046
  }
4047
4047
  if (chunk === "s" && proposal && proposal.token) {
4048
4048
  S.settingsAction = "tele-share";
4049
- S.settingsInput = `Send ${proposal.events} event(s) to ${proposal.target} now? Local events are retained. [y/N]`;
4049
+ S.settingsInput = `Send ${proposal.events} event(s) now? Local events are retained. [y/N]`;
4050
4050
  render(); return;
4051
4051
  }
4052
4052
  if (key.name === "escape" || key.name === "left") {
@@ -76,6 +76,24 @@ function copyFile(src, dest) {
76
76
  try { fs.chmodSync(dest, fs.statSync(src).mode & 0o777); } catch {}
77
77
  }
78
78
 
79
+ // The generated settings adapter may replace a stale template settings.json —
80
+ // one carrying nothing the adapter lacks — but an org's customized settings
81
+ // (extra hook events or permission allows, e.g. PreCompact/Stop) are
82
+ // authoritative and are never adopted, even when clean and tracked.
83
+ function settingsSafeToAdopt(destFile, srcFile) {
84
+ try {
85
+ const local = JSON.parse(fs.readFileSync(destFile, "utf8"));
86
+ const bundle = JSON.parse(fs.readFileSync(srcFile, "utf8"));
87
+ const bundleHooks = bundle.hooks || {};
88
+ const extraHooks = Object.keys(local.hooks || {}).filter((k) => !(k in bundleHooks));
89
+ const bundleAllow = new Set(bundle.permissions?.allow || []);
90
+ const extraAllow = (local.permissions?.allow || []).filter((entry) => !bundleAllow.has(entry));
91
+ return extraHooks.length === 0 && extraAllow.length === 0;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
79
97
  // One-time ownership migration for pre-bundle Claude installs. A clean,
80
98
  // tracked path is part of the cloned framework template and may be adopted by
81
99
  // the package. Dirty/untracked paths are user work and remain untouched.
@@ -153,7 +171,11 @@ function installClaudeRuntime(egregoreDir) {
153
171
  summary.skipped += 1; record();
154
172
  } else if (previousHash && currentHash === previousHash) {
155
173
  copyFile(src, dest); summary.updated += 1; record();
156
- } else if (!previousHash && isCleanTrackedFrameworkFile(egregoreDir, rel)) {
174
+ } else if (
175
+ !previousHash
176
+ && (rel.replaceAll("\\", "/") !== ".claude/settings.json" || settingsSafeToAdopt(dest, src))
177
+ && isCleanTrackedFrameworkFile(egregoreDir, rel)
178
+ ) {
157
179
  copyFile(src, dest); summary.updated += 1; record();
158
180
  } else {
159
181
  summary.conflicted += 1; summary.conflicts.push(rel);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.21.0-runtime-mvp.9",
3
+ "version": "0.21.0-runtime-mvp.11",
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": {
@@ -24,6 +24,10 @@ FRAMEWORK_UPDATED_FILES=""
24
24
  HEALTH_RETRIEVAL="skip"
25
25
  SYNC_RECEIPT=""
26
26
 
27
+ # Settings drift healer + unshared-settings reporting (see the lib header).
28
+ # shellcheck source=bin/lib/settings-drift.sh
29
+ source "$SCRIPT_DIR/bin/lib/settings-drift.sh"
30
+
27
31
  # The core repo's integration branch: "develop" unless egregore.json sets
28
32
  # base_branch. session-start.sh resolves this before sourcing; resolve it here
29
33
  # too so the lib stands alone for other callers.
@@ -324,8 +328,14 @@ setup_develop() {
324
328
  return 1
325
329
  fi
326
330
  else
327
- # Already on it — fetch couldn't update a checked-out branch, so pull
331
+ # Already on it — fetch couldn't update a checked-out branch, so pull.
332
+ # Launcher/installer settings drift blocks this merge and the refusal
333
+ # is otherwise silent; heal it first, re-apply the launcher's intent
334
+ # after (bin/lib/settings-drift.sh).
335
+ settings_drift_heal
328
336
  git merge --ff-only "origin/$BASE_BRANCH" --quiet 2>/dev/null || true
337
+ settings_drift_reapply
338
+ settings_drift_report
329
339
  fi
330
340
 
331
341
  # Never report the intended branch as the actual branch. A checkout can fail
@@ -95,6 +95,14 @@ 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
102
+ if [ -n "${UNSHARED_SETTINGS_KEYS:-}" ]; then
103
+ echo " ◇ org settings changed on this machine (${UNSHARED_SETTINGS_KEYS}) — say \"share settings\" to make them org-wide"
104
+ fi
105
+
98
106
  # --- Footer: health + repos + memory (tertiary) ---
99
107
  echo "$SEPARATOR"
100
108
 
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env bash
2
+ # settings-drift.sh — heal launcher/installer settings drift around the
3
+ # base-branch fast-forward, and surface unshared org-settings changes.
4
+ #
5
+ # Two org-config surfaces accumulate legitimate local modifications outside
6
+ # any session: .claude/settings.json (the installer's generated public-safe
7
+ # adapter can replace the org's richer committed file) and egregore.json
8
+ # (launcher settings verbs: posture, workflow, people removals). Local
9
+ # modifications to files a fast-forward also changes make git refuse the
10
+ # merge — and that refusal used to be silently swallowed, leaving the
11
+ # checkout behind with no explanation.
12
+ #
13
+ # Contract (sourced; caller provides $BASE_BRANCH and runs from the repo
14
+ # root):
15
+ # settings_drift_heal — before the ff-merge. Restores settings.json
16
+ # only when the local copy is a reduced
17
+ # generated adapter (its hook events and
18
+ # permission allows are a subset of the
19
+ # committed file's). Snapshots egregore.json
20
+ # and returns it to HEAD so the merge can run.
21
+ # settings_drift_reapply — after the ff-merge. Re-applies the snapshot's
22
+ # changed settings-owned keys on top of the
23
+ # incoming egregore.json.
24
+ # settings_drift_report — sets UNSHARED_SETTINGS_KEYS to the
25
+ # settings-owned keys where the working file
26
+ # differs from HEAD, for the greeting.
27
+ #
28
+ # Anything dirty beyond these two files is user work and is never touched.
29
+
30
+ _SETTINGS_OWNED_KEYS='["boundary","base_branch","people_removed","features","repos","admins","people"]'
31
+ _SETTINGS_DRIFT_SNAPSHOT=""
32
+ _SETTINGS_DRIFT_BASE=""
33
+ # Read by bin/lib/greeting.sh after git-sync completes.
34
+ export SETTINGS_ADAPTER_RESTORED="false"
35
+ export UNSHARED_SETTINGS_KEYS=""
36
+
37
+ # The generated adapter only ever removes relative to the org file: fewer
38
+ # hook events, fewer permission allows. A local settings.json that carries
39
+ # anything the committed file lacks is user work and stays untouched.
40
+ _settings_is_reduced_adapter() {
41
+ local tmp_head
42
+ tmp_head=$(mktemp) || return 1
43
+ if ! git show "HEAD:.claude/settings.json" > "$tmp_head" 2>/dev/null; then
44
+ rm -f "$tmp_head"
45
+ return 1
46
+ fi
47
+ jq -es '
48
+ .[0] as $local | .[1] as $head |
49
+ ($local != $head) and
50
+ ((($local.hooks // {}) | keys) - (($head.hooks // {}) | keys) | length == 0) and
51
+ ((($local.permissions.allow // []) - ($head.permissions.allow // [])) | length == 0)
52
+ ' .claude/settings.json "$tmp_head" >/dev/null 2>&1
53
+ local verdict=$?
54
+ rm -f "$tmp_head"
55
+ return "$verdict"
56
+ }
57
+
58
+ settings_drift_heal() {
59
+ git rev-parse -q --verify "origin/$BASE_BRANCH" >/dev/null 2>&1 || return 0
60
+ [ "$(git rev-parse HEAD 2>/dev/null)" = "$(git rev-parse "origin/$BASE_BRANCH" 2>/dev/null)" ] && return 0
61
+
62
+ if ! git diff --quiet -- .claude/settings.json 2>/dev/null; then
63
+ if _settings_is_reduced_adapter; then
64
+ git checkout -- .claude/settings.json 2>/dev/null && SETTINGS_ADAPTER_RESTORED="true"
65
+ fi
66
+ fi
67
+
68
+ if ! git diff --quiet -- egregore.json 2>/dev/null; then
69
+ _SETTINGS_DRIFT_SNAPSHOT=$(cat egregore.json 2>/dev/null || true)
70
+ _SETTINGS_DRIFT_BASE=$(git show "HEAD:egregore.json" 2>/dev/null || true)
71
+ if [ -n "$_SETTINGS_DRIFT_SNAPSHOT" ] && [ -n "$_SETTINGS_DRIFT_BASE" ] \
72
+ && printf '%s' "$_SETTINGS_DRIFT_SNAPSHOT" | jq -e . >/dev/null 2>&1; then
73
+ git checkout -- egregore.json 2>/dev/null || _SETTINGS_DRIFT_SNAPSHOT=""
74
+ else
75
+ _SETTINGS_DRIFT_SNAPSHOT=""
76
+ fi
77
+ fi
78
+ }
79
+
80
+ settings_drift_reapply() {
81
+ [ -n "$_SETTINGS_DRIFT_SNAPSHOT" ] || return 0
82
+ local tmp_local tmp_base merged
83
+ tmp_local=$(mktemp) && tmp_base=$(mktemp) || return 0
84
+ printf '%s' "$_SETTINGS_DRIFT_SNAPSHOT" > "$tmp_local"
85
+ printf '%s' "$_SETTINGS_DRIFT_BASE" > "$tmp_base"
86
+ merged=$(jq -s --argjson keys "$_SETTINGS_OWNED_KEYS" '
87
+ .[0] as $incoming | .[1] as $local | .[2] as $base |
88
+ reduce $keys[] as $k ($incoming;
89
+ if ($local[$k] // null) != ($base[$k] // null) then
90
+ if ($local | has($k)) then .[$k] = $local[$k] else del(.[$k]) end
91
+ else . end)
92
+ ' egregore.json "$tmp_local" "$tmp_base" 2>/dev/null || true)
93
+ rm -f "$tmp_local" "$tmp_base"
94
+ if [ -n "$merged" ] && printf '%s' "$merged" | jq -e . >/dev/null 2>&1; then
95
+ printf '%s\n' "$merged" > egregore.json
96
+ fi
97
+ _SETTINGS_DRIFT_SNAPSHOT=""
98
+ _SETTINGS_DRIFT_BASE=""
99
+ }
100
+
101
+ settings_drift_report() {
102
+ UNSHARED_SETTINGS_KEYS=""
103
+ git diff --quiet -- egregore.json 2>/dev/null && return 0
104
+ local tmp_head
105
+ tmp_head=$(mktemp) || return 0
106
+ if ! git show "HEAD:egregore.json" > "$tmp_head" 2>/dev/null; then
107
+ rm -f "$tmp_head"
108
+ return 0
109
+ fi
110
+ UNSHARED_SETTINGS_KEYS=$(jq -rs --argjson keys "$_SETTINGS_OWNED_KEYS" '
111
+ .[0] as $local | .[1] as $head |
112
+ [$keys[] | select(($local[.] // null) != ($head[.] // null))] | join(", ")
113
+ ' egregore.json "$tmp_head" 2>/dev/null || true)
114
+ rm -f "$tmp_head"
115
+ export UNSHARED_SETTINGS_KEYS
116
+ }
@@ -235,6 +235,21 @@ cmd_query() {
235
235
  } >&2
236
236
  return "$query_rc"
237
237
  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.
241
+ local _tele_count _tele_ms
242
+ _tele_ms=$(( ($(date +%s) - t0) * 1000 ))
243
+ _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
+ if [[ "$_tele_count" =~ ^[0-9]+$ ]]; then
245
+ 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 &
248
+ else
249
+ 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 &
251
+ fi
252
+
238
253
  [ "$enrich" = 1 ] && emit="_enrich"
239
254
  if [ "$context_packet" = 1 ]; then
240
255
  local packet
@@ -146,7 +146,7 @@ cmd_workflow() {
146
146
  || _die "cannot switch workflow: origin/main does not exist or could not be reached" 1
147
147
  _save '.base_branch = "main"'
148
148
  fi
149
- echo "Git workflow: SIMPLE — pull requests now target main"
149
+ echo "Git workflow: SIMPLE — pull requests now target main · /save shares it with the org"
150
150
  ;;
151
151
  staged)
152
152
  if [ "$base" != "develop" ]; then
@@ -160,7 +160,7 @@ cmd_workflow() {
160
160
  elif ! jq -e 'has("base_branch")' "$CONFIG" >/dev/null 2>&1; then
161
161
  _save '.base_branch = "develop"'
162
162
  fi
163
- echo "Git workflow: STAGED — pull requests now target develop; releases promote to main"
163
+ echo "Git workflow: STAGED — pull requests now target develop; releases promote to main · /save shares it with the org"
164
164
  ;;
165
165
  *) _die "usage: settings.sh workflow status|simple|staged" 1 ;;
166
166
  esac
@@ -468,7 +468,7 @@ cmd_posture() {
468
468
  if [ "$action" != "$current" ]; then
469
469
  _save '.boundary = ((.boundary // {}) + {posture: $p})' --arg p "$action"
470
470
  fi
471
- echo "Boundary posture: $action"
471
+ echo "Boundary posture: $action · applies to new sessions · /save shares it with the org"
472
472
  ;;
473
473
  *) _die "usage: settings.sh posture status|strict|standard|open" 1 ;;
474
474
  esac
@@ -27,6 +27,10 @@ fi
27
27
  BUFFER_FILE="$BUFFER_DIR/telemetry.jsonl"
28
28
  LOCK_DIR="$BUFFER_DIR/telemetry.lock"
29
29
  SHARE_CONSENT_FILE="$BUFFER_DIR/telemetry-share-consent.json"
30
+ # Exact dataset pinned at proposal time. The confirm step sends this snapshot,
31
+ # so events emitted between proposal and confirm (attendant, other sessions)
32
+ # can never invalidate — or silently join — a disclosed share.
33
+ SHARE_STAGED_FILE="$BUFFER_DIR/telemetry.share-staged.jsonl"
30
34
  STATE_FILE="${EGREGORE_STATE_FILE:-$SCRIPT_DIR/.egregore-state.json}"
31
35
  CONFIG="${EGREGORE_CONFIG_FILE:-$SCRIPT_DIR/egregore.json}"
32
36
  ENV_FILE="${EGREGORE_ENV_FILE:-$SCRIPT_DIR/.env}"
@@ -230,7 +234,8 @@ _validate_metrics() {
230
234
  }
231
235
 
232
236
  _validate_buffer() {
233
- [ -s "$BUFFER_FILE" ] || return 0
237
+ local target="${1:-$BUFFER_FILE}"
238
+ [ -s "$target" ] || return 0
234
239
  jq -e --arg schema "$SCHEMA_VERSION" --argjson allowed "$ALLOWED_METRICS_JSON" '
235
240
  def safe_id:
236
241
  type == "string" and length > 0 and length <= 256 and
@@ -264,7 +269,7 @@ _validate_buffer() {
264
269
  (.data | safe_metrics) and (.ts | type == "string") and
265
270
  (.type | safe_id) and (.sid | safe_id) and (.org | safe_id) and (.user | safe_id)
266
271
  end
267
- ' "$BUFFER_FILE" >/dev/null 2>&1
272
+ ' "$target" >/dev/null 2>&1
268
273
  }
269
274
 
270
275
  cmd_emit() {
@@ -422,7 +427,7 @@ cmd_clear() {
422
427
  echo "Could not acquire the telemetry lock; nothing was cleared." >&2
423
428
  return 1
424
429
  fi
425
- rm -f "$BUFFER_FILE" "$SHARE_CONSENT_FILE"
430
+ rm -f "$BUFFER_FILE" "$SHARE_CONSENT_FILE" "$SHARE_STAGED_FILE"
426
431
  _unlock
427
432
  echo "Local telemetry buffer cleared."
428
433
  }
@@ -525,31 +530,35 @@ cmd_share() {
525
530
  https://*|http://127.0.0.1*|http://localhost*|http://\[::1\]*) ;;
526
531
  *) echo "Share endpoint must use https (or loopback http)." >&2; return 2 ;;
527
532
  esac
528
- if ! _validate_buffer; then
533
+ # Proposal validates the live buffer it is about to stage; confirm validates
534
+ # the staged snapshot it is about to send — never the moving buffer.
535
+ if [ -z "$confirmation" ] && ! _validate_buffer; then
529
536
  echo "Share blocked: the local buffer contains an invalid or content-bearing event." >&2
530
537
  return 2
531
538
  fi
532
-
533
- local count=0 size=0 dataset_sha256=""
534
- if [ -s "$BUFFER_FILE" ]; then
535
- count=$(grep -c '^{' "$BUFFER_FILE" 2>/dev/null || true)
536
- size=$(wc -c < "$BUFFER_FILE" 2>/dev/null | tr -d ' ')
537
- dataset_sha256=$(_sha256_file "$BUFFER_FILE")
538
- else
539
- local empty_file="$BUFFER_DIR/telemetry.empty.$$"
540
- mkdir -p "$BUFFER_DIR"
541
- : > "$empty_file"
542
- dataset_sha256=$(_sha256_file "$empty_file")
543
- rm -f "$empty_file"
539
+ if [ -n "$confirmation" ] && ! _validate_buffer "$SHARE_STAGED_FILE"; then
540
+ echo "Share blocked: the staged dataset contains an invalid or content-bearing event." >&2
541
+ return 2
544
542
  fi
545
543
 
544
+ local count=0 size=0 dataset_sha256=""
546
545
  if [ -z "$confirmation" ]; then
547
- local fields="none"
546
+ mkdir -p "$BUFFER_DIR"
548
547
  if [ -s "$BUFFER_FILE" ]; then
548
+ cp "$BUFFER_FILE" "$SHARE_STAGED_FILE"
549
+ else
550
+ : > "$SHARE_STAGED_FILE"
551
+ fi
552
+ chmod 600 "$SHARE_STAGED_FILE"
553
+ count=$(grep -c '^{' "$SHARE_STAGED_FILE" 2>/dev/null || true)
554
+ size=$(wc -c < "$SHARE_STAGED_FILE" 2>/dev/null | tr -d ' ')
555
+ dataset_sha256=$(_sha256_file "$SHARE_STAGED_FILE")
556
+ local fields="none"
557
+ if [ -s "$SHARE_STAGED_FILE" ]; then
549
558
  fields=$(jq -rs '
550
559
  [.[].metrics // .[].data // {} | keys[]] | unique |
551
560
  if length == 0 then "none" else map("metrics." + .) | join(", ") end
552
- ' "$BUFFER_FILE" 2>/dev/null || echo "unable to inspect")
561
+ ' "$SHARE_STAGED_FILE" 2>/dev/null || echo "unable to inspect")
553
562
  fi
554
563
  local token token_sha256 expires_at
555
564
  token=$(_random_token)
@@ -600,10 +609,14 @@ cmd_share() {
600
609
  return 0
601
610
  fi
602
611
 
603
- if [ ! -f "$SHARE_CONSENT_FILE" ]; then
612
+ if [ ! -f "$SHARE_CONSENT_FILE" ] || [ ! -f "$SHARE_STAGED_FILE" ]; then
604
613
  echo "Share blocked: run share without --confirm to inspect and prepare this dataset first." >&2
605
614
  return 2
606
615
  fi
616
+ # The staged snapshot is the disclosed dataset: exactly what the proposal
617
+ # named is what ships, and later buffer growth cannot invalidate it.
618
+ count=$(grep -c '^{' "$SHARE_STAGED_FILE" 2>/dev/null || true)
619
+ dataset_sha256=$(_sha256_file "$SHARE_STAGED_FILE")
607
620
  local expected_target expected_dataset expected_token expires_at actual_token
608
621
  expected_target=$(jq -r '.target // empty' "$SHARE_CONSENT_FILE")
609
622
  expected_dataset=$(jq -r '.dataset_sha256 // empty' "$SHARE_CONSENT_FILE")
@@ -611,7 +624,7 @@ cmd_share() {
611
624
  expires_at=$(jq -r '.expires_at // 0' "$SHARE_CONSENT_FILE")
612
625
  actual_token=$(_sha256_text "$confirmation")
613
626
  if [ "$(date +%s)" -gt "$expires_at" ] 2>/dev/null; then
614
- rm -f "$SHARE_CONSENT_FILE"
627
+ rm -f "$SHARE_CONSENT_FILE" "$SHARE_STAGED_FILE"
615
628
  echo "Share blocked: confirmation expired; prepare a new share." >&2
616
629
  return 2
617
630
  fi
@@ -621,7 +634,7 @@ cmd_share() {
621
634
  fi
622
635
 
623
636
  local share_file="$BUFFER_DIR/telemetry.share.$$"
624
- if [ -s "$BUFFER_FILE" ]; then
637
+ if [ -s "$SHARE_STAGED_FILE" ]; then
625
638
  jq -c --arg schema "$SCHEMA_VERSION" '
626
639
  if .schema_version == $schema then
627
640
  {ts:.occurred_at,type:.event_type,sid:.session_id,org:.org_id,user:.actor_id,
@@ -629,7 +642,7 @@ cmd_share() {
629
642
  artifact_ids:.artifact_ids,task_id:.task_id,org_revision:.org_revision,
630
643
  index_spec_version:.index_spec_version,shared:true})}
631
644
  else . end
632
- ' "$BUFFER_FILE" > "$share_file"
645
+ ' "$SHARE_STAGED_FILE" > "$share_file"
633
646
  else
634
647
  : > "$share_file"
635
648
  fi
@@ -646,7 +659,7 @@ cmd_share() {
646
659
  http_code=$(curl "${curl_args[@]}" 2>/dev/null || echo "000")
647
660
  rm -f "$share_file"
648
661
  if [ "$http_code" -ge 200 ] 2>/dev/null && [ "$http_code" -lt 300 ] 2>/dev/null; then
649
- rm -f "$SHARE_CONSENT_FILE"
662
+ rm -f "$SHARE_CONSENT_FILE" "$SHARE_STAGED_FILE"
650
663
  echo "Shared $count telemetry events with $endpoint. Local events were retained."
651
664
  else
652
665
  echo "Share failed (HTTP $http_code). Nothing was removed locally." >&2
@@ -61,7 +61,7 @@ def _snapshot(runtime, actor, service, permission: Permission = Permission.READ)
61
61
 
62
62
 
63
63
  def _resolve(runtime, actor, service, reference: str) -> IssueSnapshot:
64
- rows, _ = _snapshot(runtime, actor)
64
+ rows, _ = _snapshot(runtime, actor, service)
65
65
  return service.resolve(rows, reference)
66
66
 
67
67
 
@@ -72,7 +72,7 @@ def _write_result(issue: IssueSnapshot, receipt) -> int:
72
72
 
73
73
  def _list(args):
74
74
  _, runtime, actor, service = _context()
75
- rows, _ = _snapshot(runtime, actor)
75
+ rows, _ = _snapshot(runtime, actor, service)
76
76
  selected = rows if args.status == "all" else tuple(
77
77
  row for row in rows if row.status == args.status
78
78
  )
@@ -89,7 +89,7 @@ def _show(args):
89
89
 
90
90
  def _search(args):
91
91
  _, runtime, actor, service = _context()
92
- rows, _ = _snapshot(runtime, actor)
92
+ rows, _ = _snapshot(runtime, actor, service)
93
93
  terms = tuple(part for part in args.term.casefold().split() if part)
94
94
 
95
95
  def matches(row: IssueSnapshot) -> bool:
@@ -184,6 +184,7 @@
184
184
  "bin/lib/metrics.sh",
185
185
  "bin/lib/noncode.sh",
186
186
  "bin/lib/permissions.sh",
187
+ "bin/lib/settings-drift.sh",
187
188
  "bin/lib/time.sh",
188
189
  "bin/lib/worktree-links.sh",
189
190
  "bin/node-run.sh",
@@ -24,6 +24,10 @@ FRAMEWORK_UPDATED_FILES=""
24
24
  HEALTH_RETRIEVAL="skip"
25
25
  SYNC_RECEIPT=""
26
26
 
27
+ # Settings drift healer + unshared-settings reporting (see the lib header).
28
+ # shellcheck source=bin/lib/settings-drift.sh
29
+ source "$SCRIPT_DIR/bin/lib/settings-drift.sh"
30
+
27
31
  # The core repo's integration branch: "develop" unless egregore.json sets
28
32
  # base_branch. session-start.sh resolves this before sourcing; resolve it here
29
33
  # too so the lib stands alone for other callers.
@@ -324,8 +328,14 @@ setup_develop() {
324
328
  return 1
325
329
  fi
326
330
  else
327
- # Already on it — fetch couldn't update a checked-out branch, so pull
331
+ # Already on it — fetch couldn't update a checked-out branch, so pull.
332
+ # Launcher/installer settings drift blocks this merge and the refusal
333
+ # is otherwise silent; heal it first, re-apply the launcher's intent
334
+ # after (bin/lib/settings-drift.sh).
335
+ settings_drift_heal
328
336
  git merge --ff-only "origin/$BASE_BRANCH" --quiet 2>/dev/null || true
337
+ settings_drift_reapply
338
+ settings_drift_report
329
339
  fi
330
340
 
331
341
  # Never report the intended branch as the actual branch. A checkout can fail
@@ -95,6 +95,14 @@ 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
102
+ if [ -n "${UNSHARED_SETTINGS_KEYS:-}" ]; then
103
+ echo " ◇ org settings changed on this machine (${UNSHARED_SETTINGS_KEYS}) — say \"share settings\" to make them org-wide"
104
+ fi
105
+
98
106
  # --- Footer: health + repos + memory (tertiary) ---
99
107
  echo "$SEPARATOR"
100
108
 
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env bash
2
+ # settings-drift.sh — heal launcher/installer settings drift around the
3
+ # base-branch fast-forward, and surface unshared org-settings changes.
4
+ #
5
+ # Two org-config surfaces accumulate legitimate local modifications outside
6
+ # any session: .claude/settings.json (the installer's generated public-safe
7
+ # adapter can replace the org's richer committed file) and egregore.json
8
+ # (launcher settings verbs: posture, workflow, people removals). Local
9
+ # modifications to files a fast-forward also changes make git refuse the
10
+ # merge — and that refusal used to be silently swallowed, leaving the
11
+ # checkout behind with no explanation.
12
+ #
13
+ # Contract (sourced; caller provides $BASE_BRANCH and runs from the repo
14
+ # root):
15
+ # settings_drift_heal — before the ff-merge. Restores settings.json
16
+ # only when the local copy is a reduced
17
+ # generated adapter (its hook events and
18
+ # permission allows are a subset of the
19
+ # committed file's). Snapshots egregore.json
20
+ # and returns it to HEAD so the merge can run.
21
+ # settings_drift_reapply — after the ff-merge. Re-applies the snapshot's
22
+ # changed settings-owned keys on top of the
23
+ # incoming egregore.json.
24
+ # settings_drift_report — sets UNSHARED_SETTINGS_KEYS to the
25
+ # settings-owned keys where the working file
26
+ # differs from HEAD, for the greeting.
27
+ #
28
+ # Anything dirty beyond these two files is user work and is never touched.
29
+
30
+ _SETTINGS_OWNED_KEYS='["boundary","base_branch","people_removed","features","repos","admins","people"]'
31
+ _SETTINGS_DRIFT_SNAPSHOT=""
32
+ _SETTINGS_DRIFT_BASE=""
33
+ # Read by bin/lib/greeting.sh after git-sync completes.
34
+ export SETTINGS_ADAPTER_RESTORED="false"
35
+ export UNSHARED_SETTINGS_KEYS=""
36
+
37
+ # The generated adapter only ever removes relative to the org file: fewer
38
+ # hook events, fewer permission allows. A local settings.json that carries
39
+ # anything the committed file lacks is user work and stays untouched.
40
+ _settings_is_reduced_adapter() {
41
+ local tmp_head
42
+ tmp_head=$(mktemp) || return 1
43
+ if ! git show "HEAD:.claude/settings.json" > "$tmp_head" 2>/dev/null; then
44
+ rm -f "$tmp_head"
45
+ return 1
46
+ fi
47
+ jq -es '
48
+ .[0] as $local | .[1] as $head |
49
+ ($local != $head) and
50
+ ((($local.hooks // {}) | keys) - (($head.hooks // {}) | keys) | length == 0) and
51
+ ((($local.permissions.allow // []) - ($head.permissions.allow // [])) | length == 0)
52
+ ' .claude/settings.json "$tmp_head" >/dev/null 2>&1
53
+ local verdict=$?
54
+ rm -f "$tmp_head"
55
+ return "$verdict"
56
+ }
57
+
58
+ settings_drift_heal() {
59
+ git rev-parse -q --verify "origin/$BASE_BRANCH" >/dev/null 2>&1 || return 0
60
+ [ "$(git rev-parse HEAD 2>/dev/null)" = "$(git rev-parse "origin/$BASE_BRANCH" 2>/dev/null)" ] && return 0
61
+
62
+ if ! git diff --quiet -- .claude/settings.json 2>/dev/null; then
63
+ if _settings_is_reduced_adapter; then
64
+ git checkout -- .claude/settings.json 2>/dev/null && SETTINGS_ADAPTER_RESTORED="true"
65
+ fi
66
+ fi
67
+
68
+ if ! git diff --quiet -- egregore.json 2>/dev/null; then
69
+ _SETTINGS_DRIFT_SNAPSHOT=$(cat egregore.json 2>/dev/null || true)
70
+ _SETTINGS_DRIFT_BASE=$(git show "HEAD:egregore.json" 2>/dev/null || true)
71
+ if [ -n "$_SETTINGS_DRIFT_SNAPSHOT" ] && [ -n "$_SETTINGS_DRIFT_BASE" ] \
72
+ && printf '%s' "$_SETTINGS_DRIFT_SNAPSHOT" | jq -e . >/dev/null 2>&1; then
73
+ git checkout -- egregore.json 2>/dev/null || _SETTINGS_DRIFT_SNAPSHOT=""
74
+ else
75
+ _SETTINGS_DRIFT_SNAPSHOT=""
76
+ fi
77
+ fi
78
+ }
79
+
80
+ settings_drift_reapply() {
81
+ [ -n "$_SETTINGS_DRIFT_SNAPSHOT" ] || return 0
82
+ local tmp_local tmp_base merged
83
+ tmp_local=$(mktemp) && tmp_base=$(mktemp) || return 0
84
+ printf '%s' "$_SETTINGS_DRIFT_SNAPSHOT" > "$tmp_local"
85
+ printf '%s' "$_SETTINGS_DRIFT_BASE" > "$tmp_base"
86
+ merged=$(jq -s --argjson keys "$_SETTINGS_OWNED_KEYS" '
87
+ .[0] as $incoming | .[1] as $local | .[2] as $base |
88
+ reduce $keys[] as $k ($incoming;
89
+ if ($local[$k] // null) != ($base[$k] // null) then
90
+ if ($local | has($k)) then .[$k] = $local[$k] else del(.[$k]) end
91
+ else . end)
92
+ ' egregore.json "$tmp_local" "$tmp_base" 2>/dev/null || true)
93
+ rm -f "$tmp_local" "$tmp_base"
94
+ if [ -n "$merged" ] && printf '%s' "$merged" | jq -e . >/dev/null 2>&1; then
95
+ printf '%s\n' "$merged" > egregore.json
96
+ fi
97
+ _SETTINGS_DRIFT_SNAPSHOT=""
98
+ _SETTINGS_DRIFT_BASE=""
99
+ }
100
+
101
+ settings_drift_report() {
102
+ UNSHARED_SETTINGS_KEYS=""
103
+ git diff --quiet -- egregore.json 2>/dev/null && return 0
104
+ local tmp_head
105
+ tmp_head=$(mktemp) || return 0
106
+ if ! git show "HEAD:egregore.json" > "$tmp_head" 2>/dev/null; then
107
+ rm -f "$tmp_head"
108
+ return 0
109
+ fi
110
+ UNSHARED_SETTINGS_KEYS=$(jq -rs --argjson keys "$_SETTINGS_OWNED_KEYS" '
111
+ .[0] as $local | .[1] as $head |
112
+ [$keys[] | select(($local[.] // null) != ($head[.] // null))] | join(", ")
113
+ ' egregore.json "$tmp_head" 2>/dev/null || true)
114
+ rm -f "$tmp_head"
115
+ export UNSHARED_SETTINGS_KEYS
116
+ }
@@ -235,6 +235,21 @@ cmd_query() {
235
235
  } >&2
236
236
  return "$query_rc"
237
237
  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.
241
+ local _tele_count _tele_ms
242
+ _tele_ms=$(( ($(date +%s) - t0) * 1000 ))
243
+ _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
+ if [[ "$_tele_count" =~ ^[0-9]+$ ]]; then
245
+ 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 &
248
+ else
249
+ 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 &
251
+ fi
252
+
238
253
  [ "$enrich" = 1 ] && emit="_enrich"
239
254
  if [ "$context_packet" = 1 ]; then
240
255
  local packet