create-egregore 0.21.0-runtime-mvp.10 → 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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // egregore-launcher-version: 0.21.0-runtime-mvp.10
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.10";
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");
@@ -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.10",
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
+ }
@@ -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
+ }
@@ -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:
@@ -171,6 +171,7 @@
171
171
  "bin/lib/metrics.sh",
172
172
  "bin/lib/noncode.sh",
173
173
  "bin/lib/permissions.sh",
174
+ "bin/lib/settings-drift.sh",
174
175
  "bin/lib/time.sh",
175
176
  "bin/lib/worktree-links.sh",
176
177
  "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
+ }
@@ -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:
@@ -174,6 +174,7 @@
174
174
  "bin/lib/metrics.sh",
175
175
  "bin/lib/noncode.sh",
176
176
  "bin/lib/permissions.sh",
177
+ "bin/lib/settings-drift.sh",
177
178
  "bin/lib/time.sh",
178
179
  "bin/lib/worktree-links.sh",
179
180
  "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
+ }
@@ -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:
@@ -177,6 +177,7 @@
177
177
  "bin/lib/metrics.sh",
178
178
  "bin/lib/noncode.sh",
179
179
  "bin/lib/permissions.sh",
180
+ "bin/lib/settings-drift.sh",
180
181
  "bin/lib/time.sh",
181
182
  "bin/lib/worktree-links.sh",
182
183
  "bin/node-run.sh",