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

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.14
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.14";
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.14",
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
 
@@ -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
 
@@ -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
 
@@ -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
 
@@ -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)