create-egregore 0.21.0-runtime-mvp.10 → 0.21.0-runtime-mvp.12

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 (39) hide show
  1. package/assets/egregore-launcher.js +2 -2
  2. package/lib/claude-runtime.js +23 -1
  3. package/package.json +1 -1
  4. package/runtime/claude/.claude/skills/handoff/SKILL.md +13 -0
  5. package/runtime/claude/bin/codex-sync-skills.sh +1 -0
  6. package/runtime/claude/bin/handoff-preview.sh +71 -7
  7. package/runtime/claude/bin/lib/git-sync.sh +14 -1
  8. package/runtime/claude/bin/lib/greeting.sh +14 -0
  9. package/runtime/claude/bin/lib/settings-drift.sh +196 -0
  10. package/runtime/claude/bin/settings.sh +36 -0
  11. package/runtime/claude/egregore_runtime/issue_cli.py +3 -3
  12. package/runtime/claude/manifest.json +1 -0
  13. package/runtime/codex/.codex/skills/handoff/SKILL.md +13 -0
  14. package/runtime/codex/bin/codex-sync-skills.sh +1 -0
  15. package/runtime/codex/bin/handoff-preview.sh +71 -7
  16. package/runtime/codex/bin/lib/git-sync.sh +14 -1
  17. package/runtime/codex/bin/lib/greeting.sh +14 -0
  18. package/runtime/codex/bin/lib/settings-drift.sh +196 -0
  19. package/runtime/codex/bin/settings.sh +36 -0
  20. package/runtime/codex/egregore_runtime/issue_cli.py +3 -3
  21. package/runtime/codex/manifest.json +1 -0
  22. package/runtime/pi/.codex/skills/handoff/SKILL.md +13 -0
  23. package/runtime/pi/bin/codex-sync-skills.sh +1 -0
  24. package/runtime/pi/bin/handoff-preview.sh +71 -7
  25. package/runtime/pi/bin/lib/git-sync.sh +14 -1
  26. package/runtime/pi/bin/lib/greeting.sh +14 -0
  27. package/runtime/pi/bin/lib/settings-drift.sh +196 -0
  28. package/runtime/pi/bin/settings.sh +36 -0
  29. package/runtime/pi/egregore_runtime/issue_cli.py +3 -3
  30. package/runtime/pi/manifest.json +1 -0
  31. package/runtime/prime/.codex/skills/handoff/SKILL.md +13 -0
  32. package/runtime/prime/bin/codex-sync-skills.sh +1 -0
  33. package/runtime/prime/bin/handoff-preview.sh +71 -7
  34. package/runtime/prime/bin/lib/git-sync.sh +14 -1
  35. package/runtime/prime/bin/lib/greeting.sh +14 -0
  36. package/runtime/prime/bin/lib/settings-drift.sh +196 -0
  37. package/runtime/prime/bin/settings.sh +36 -0
  38. package/runtime/prime/egregore_runtime/issue_cli.py +3 -3
  39. 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.10
2
+ // egregore-launcher-version: 0.21.0-runtime-mvp.12
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.12";
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.12",
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": {
@@ -74,6 +74,19 @@ Preserve supplied wording, order, headings, lists, code, and links. For
74
74
  generated content, use only the useful subset of Briefing, Current State, Open
75
75
  Threads, and Next Steps. Keep it operational and concise.
76
76
 
77
+ ## Staircase (staging)
78
+
79
+ A second face for addressed handoffs exists: the staircase, a
80
+ progressive-disclosure page the receiver walks one step at a time and closes
81
+ with a readback (docs/specs/handoff-staircase.md). It is in staging and does
82
+ not change this skill. When the user asks for a staircase and this instance
83
+ has the staging command (.claude/skills/handoff-staircase/SKILL.md, Codex: the
84
+ handoff-staircase adapter; internal to Curve Labs while in staging), route to
85
+ it: it reuses this preview and approval bridge with a composed spec and adds
86
+ one feedback question at the end. Without it, say the staircase is not
87
+ installed here and continue with this skill. When its feedback
88
+ promotes it, the staircase becomes the default here and that command retires.
89
+
77
90
  ## One-command preview
78
91
 
79
92
  Pipe the exact canonical Markdown once to the private preview bridge. It stores
@@ -26,6 +26,7 @@ NATIVE_SKILLS=(
26
26
  )
27
27
 
28
28
  STRUCTURED_UX_SKILLS=(
29
+ handoff-staircase
29
30
  activity
30
31
  dashboard
31
32
  handoff
@@ -13,7 +13,7 @@ usage() {
13
13
  usage:
14
14
  bash bin/handoff-preview.sh preview --author HANDLE --recipient HANDLE \
15
15
  --topic TOPIC --intent action|feedback|fyi \
16
- --content-mode supplied|generated < handoff.md
16
+ --content-mode supplied|generated [--composed SPEC.json] < handoff.md
17
17
  bash bin/handoff-preview.sh approve TOKEN
18
18
  EOF
19
19
  }
@@ -44,7 +44,7 @@ validate_token() {
44
44
  }
45
45
 
46
46
  preview() {
47
- local author="" recipient="" topic="" intent="" content_mode=""
47
+ local author="" recipient="" topic="" intent="" content_mode="" composed=""
48
48
  while [ $# -gt 0 ]; do
49
49
  case "$1" in
50
50
  --author) author="${2:-}"; shift 2 ;;
@@ -52,6 +52,7 @@ preview() {
52
52
  --topic) topic="${2:-}"; shift 2 ;;
53
53
  --intent) intent="${2:-}"; shift 2 ;;
54
54
  --content-mode) content_mode="${2:-}"; shift 2 ;;
55
+ --composed) composed="${2:-}"; shift 2 ;;
55
56
  *) echo "unknown preview option: $1" >&2; usage; exit 2 ;;
56
57
  esac
57
58
  done
@@ -60,16 +61,25 @@ preview() {
60
61
  [ -n "$topic" ] || { echo "handoff preview requires --topic" >&2; exit 2; }
61
62
  case "$intent" in action|feedback|fyi) ;; *) echo "invalid handoff intent" >&2; exit 2 ;; esac
62
63
  case "$content_mode" in supplied|generated) ;; *) echo "invalid handoff content mode" >&2; exit 2 ;; esac
64
+ if [ -n "$composed" ]; then
65
+ # A composed spec (for example a staircase) is a presentation map over the
66
+ # canonical Markdown. It rides the same private record and is judged by the
67
+ # same fidelity gate; it never replaces the Markdown as the record.
68
+ [ "$content_mode" = "generated" ] || { echo "--composed requires --content-mode generated" >&2; exit 2; }
69
+ [ -s "$composed" ] || { echo "handoff preview --composed file is missing or empty" >&2; exit 2; }
70
+ jq -e . "$composed" >/dev/null 2>&1 || { echo "handoff preview --composed file is not JSON" >&2; exit 2; }
71
+ fi
63
72
 
64
73
  umask 077
65
74
  mkdir -p "$STORE_ROOT"
66
75
  chmod 700 "$STORE_ROOT"
67
76
 
68
- local token content_file metadata_file digest
77
+ local token content_file metadata_file spec_file digest spec_digest
69
78
  token="$(new_token)"
70
79
  validate_token "$token"
71
80
  content_file="$STORE_ROOT/$token.md"
72
81
  metadata_file="$STORE_ROOT/$token.json"
82
+ spec_file="$STORE_ROOT/$token.spec.json"
73
83
  cat > "$content_file"
74
84
  [ -s "$content_file" ] || {
75
85
  rm -f "$content_file"
@@ -77,6 +87,11 @@ preview() {
77
87
  exit 2
78
88
  }
79
89
  digest="$(sha256_file "$content_file")"
90
+ spec_digest=""
91
+ if [ -n "$composed" ]; then
92
+ cp "$composed" "$spec_file"
93
+ spec_digest="$(sha256_file "$spec_file")"
94
+ fi
80
95
  jq -n \
81
96
  --arg author "$author" \
82
97
  --arg recipient "$recipient" \
@@ -84,14 +99,25 @@ preview() {
84
99
  --arg intent "$intent" \
85
100
  --arg contentMode "$content_mode" \
86
101
  --arg digest "$digest" \
102
+ --arg specDigest "$spec_digest" \
87
103
  --argjson createdAt "$(date +%s)" \
88
- '{author:$author,recipient:$recipient,topic:$topic,intent:$intent,contentMode:$contentMode,digest:$digest,createdAt:$createdAt}' \
104
+ '{author:$author,recipient:$recipient,topic:$topic,intent:$intent,contentMode:$contentMode,digest:$digest,specDigest:$specDigest,createdAt:$createdAt}' \
89
105
  > "$metadata_file"
90
106
 
91
- if ! bash "$ROOT/bin/node-run.sh" \
107
+ local render_ok=0
108
+ if [ -n "$composed" ]; then
109
+ if bash "$ROOT/bin/node-run.sh" \
110
+ "$ROOT/packages/egregore-artifacts/bin/cli.js" composed "$spec_file" \
111
+ --source "$content_file" --verify-fidelity; then
112
+ render_ok=1
113
+ fi
114
+ elif bash "$ROOT/bin/node-run.sh" \
92
115
  "$ROOT/packages/egregore-artifacts/bin/cli.js" handoff "$content_file" \
93
116
  --verify-fidelity; then
94
- rm -f "$content_file" "$metadata_file"
117
+ render_ok=1
118
+ fi
119
+ if [ "$render_ok" != "1" ]; then
120
+ rm -f "$content_file" "$metadata_file" "$spec_file"
95
121
  exit 1
96
122
  fi
97
123
 
@@ -132,12 +158,26 @@ approve() {
132
158
  exit 1
133
159
  fi
134
160
 
135
- local author recipient topic intent content_mode
161
+ local author recipient topic intent content_mode spec_digest
136
162
  author="$(jq -er '.author' "$metadata_file")"
137
163
  recipient="$(jq -er '.recipient' "$metadata_file")"
138
164
  topic="$(jq -er '.topic' "$metadata_file")"
139
165
  intent="$(jq -er '.intent' "$metadata_file")"
140
166
  content_mode="$(jq -er '.contentMode' "$metadata_file")"
167
+ spec_digest="$(jq -r '.specDigest // ""' "$metadata_file")"
168
+
169
+ # A previewed composed spec is bound to the same token and the same digest
170
+ # discipline as the Markdown: approval sends exactly the previewed bytes.
171
+ local spec_file="$STORE_ROOT/$token.spec.json"
172
+ if [ -n "$spec_digest" ]; then
173
+ if [ ! -f "$spec_file" ] || [ "$(sha256_file "$spec_file")" != "$spec_digest" ]; then
174
+ rmdir "$lock_dir" 2>/dev/null || true
175
+ echo "handoff preview composed spec is missing or changed after approval was requested" >&2
176
+ exit 1
177
+ fi
178
+ else
179
+ spec_file=""
180
+ fi
141
181
 
142
182
  local result_tmp
143
183
  result_tmp="$(mktemp -d -t egregore-handoff-result-XXXXXX)"
@@ -146,6 +186,7 @@ approve() {
146
186
  --intent "$intent" --content-mode "$content_mode"
147
187
  )
148
188
  [ -z "$recipient" ] || args+=(--recipient "$recipient")
189
+ [ -z "$spec_file" ] || args+=(--composed "$spec_file")
149
190
 
150
191
  if ! TMPDIR="$result_tmp" bash "$ROOT/bin/artifact-writeback.sh" "${args[@]}" \
151
192
  < "$content_file"; then
@@ -153,10 +194,33 @@ approve() {
153
194
  exit 1
154
195
  fi
155
196
 
197
+ # Persist the composed spec beside the canonical record so the face can be
198
+ # re-rendered or shared later from memory alone. The record is already
199
+ # canonical at this point; a spec persistence failure is reported, not
200
+ # retried, so the approval can never write the record twice.
201
+ if [ -n "$spec_file" ]; then
202
+ local record_abs record_rel spec_rel
203
+ record_abs="$(jq -r '.absFile // empty' "$result_tmp/capture-run-result.json" 2>/dev/null || true)"
204
+ record_rel="${record_abs#"$ROOT/"}"
205
+ case "$record_rel" in
206
+ memory/*.md)
207
+ spec_rel="${record_rel%.md}.staircase.json"
208
+ if ! bash "$ROOT/bin/artifact-writeback.sh" create \
209
+ --path "$spec_rel" --input "$spec_file" >/dev/null; then
210
+ echo "warning: the canonical record was written but its composed spec could not be persisted at $spec_rel" >&2
211
+ fi
212
+ ;;
213
+ *)
214
+ echo "warning: the canonical record path is not under memory/; the composed spec was not persisted" >&2
215
+ ;;
216
+ esac
217
+ fi
218
+
156
219
  # Consume the approved token before rendering. A renderer failure must not
157
220
  # make a successful canonical write eligible for accidental replay.
158
221
  mv "$content_file" "$result_tmp/approved-handoff.md"
159
222
  mv "$metadata_file" "$result_tmp/approved-handoff.json"
223
+ [ -z "$spec_file" ] || mv "$spec_file" "$result_tmp/approved-handoff.staircase.json"
160
224
  rmdir "$lock_dir" 2>/dev/null || true
161
225
  bash "$ROOT/bin/render-card.sh" \
162
226
  --result "$result_tmp/capture-run-result.json"
@@ -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,17 @@ 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
+ # Connected mode: reconcile with the control plane (org settings row) —
339
+ # pull a teammate's confirmed write down, or re-deliver a failed push.
340
+ settings_drift_pull
341
+ settings_drift_report
329
342
  fi
330
343
 
331
344
  # Never report the intended branch as the actual branch. A checkout can fail
@@ -95,6 +95,20 @@ 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 "${SETTINGS_SYNCED_FROM_ORG:-}" ]; then
103
+ echo " ◇ org settings synced from the control plane (${SETTINGS_SYNCED_FROM_ORG})"
104
+ fi
105
+ if [ -n "${SETTINGS_POSTURE_HELD:-}" ]; then
106
+ echo " ◇ boundary posture was set to ${SETTINGS_POSTURE_HELD} elsewhere — relaxing access needs your confirm: bash bin/settings.sh posture ${SETTINGS_POSTURE_HELD}"
107
+ 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
+
98
112
  # --- Footer: health + repos + memory (tertiary) ---
99
113
  echo "$SEPARATOR"
100
114
 
@@ -0,0 +1,196 @@
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
+ # ── control-plane sync (Connected mode) ─────────────────────────────────
102
+ # The control plane is authoritative for org settings in Connected mode: a
103
+ # teammate's confirmed CLI write lands in the orgs row immediately, and every
104
+ # session start pulls it down here. One trust rule: an incoming posture that
105
+ # RELAXES local file access is never auto-applied — it is held and surfaced
106
+ # for local confirm; tightening applies immediately. If the server has not
107
+ # moved but the local settings-owned keys have (an offline write whose push
108
+ # failed), the local state is pushed instead.
109
+ _settings_posture_rank() {
110
+ case "$1" in strict) echo 0 ;; open) echo 2 ;; *) echo 1 ;; esac
111
+ }
112
+
113
+ export SETTINGS_POSTURE_HELD=""
114
+ export SETTINGS_SYNCED_FROM_ORG=""
115
+ # true once the control plane and the local settings-owned keys agree — the
116
+ # greeting then never asks the user to share what is already org-wide.
117
+ export SETTINGS_CP_SYNCED="false"
118
+
119
+ settings_drift_pull() {
120
+ local api_url api_key resp revision applied local_subset server_settings
121
+ api_url=$(jq -r '.api_url // empty' egregore.json 2>/dev/null)
122
+ [ -n "$api_url" ] || return 0
123
+ api_key=$(grep '^EGREGORE_API_KEY=' .env 2>/dev/null | cut -d'=' -f2-)
124
+ [ -n "$api_key" ] || return 0
125
+ resp=$(curl -s --max-time 8 -H "Authorization: Bearer $api_key" "$api_url/api/org/settings" 2>/dev/null) || return 0
126
+ printf '%s' "$resp" | jq -e '.settings' >/dev/null 2>&1 || return 0
127
+ revision=$(printf '%s' "$resp" | jq -r '.settings_revision // ""')
128
+ server_settings=$(printf '%s' "$resp" | jq -c '.settings')
129
+ applied=$(jq -r '.org_settings_revision // ""' .egregore-state.json 2>/dev/null)
130
+ local_subset=$(jq -c --argjson keys "$_SETTINGS_OWNED_KEYS" \
131
+ 'with_entries(select(.key as $k | $keys | index($k)))' egregore.json 2>/dev/null)
132
+
133
+ if [ -n "$revision" ] && [ "$revision" != "$applied" ] && [ "$server_settings" != "{}" ]; then
134
+ # Server moved: apply it, holding a posture relaxation for local confirm.
135
+ local incoming_posture local_posture hold="false"
136
+ incoming_posture=$(printf '%s' "$resp" | jq -r '.settings.boundary.posture // empty')
137
+ local_posture=$(jq -r '.boundary.posture // "standard"' egregore.json 2>/dev/null)
138
+ if [ -n "$incoming_posture" ] \
139
+ && [ "$(_settings_posture_rank "$incoming_posture")" -gt "$(_settings_posture_rank "$local_posture")" ]; then
140
+ hold="true"
141
+ SETTINGS_POSTURE_HELD="$incoming_posture"
142
+ export SETTINGS_POSTURE_HELD
143
+ fi
144
+ local merged tmp
145
+ merged=$(printf '%s' "$server_settings" | jq -c \
146
+ --argjson keys "$_SETTINGS_OWNED_KEYS" --argjson hold "$hold" --slurpfile cfg egregore.json '
147
+ . as $s | $cfg[0] as $local |
148
+ reduce $keys[] as $k ($local;
149
+ if ($s | has($k)) then
150
+ if $k == "boundary" and $hold then
151
+ .boundary = (($s.boundary // {}) + {posture: ($local.boundary.posture // "standard")})
152
+ else .[$k] = $s[$k] end
153
+ else del(.[$k]) end)
154
+ ' 2>/dev/null || true)
155
+ if [ -n "$merged" ] && printf '%s' "$merged" | jq -e . >/dev/null 2>&1; then
156
+ tmp=$(mktemp "egregore.json.XXXXXX") || return 0
157
+ printf '%s\n' "$merged" | jq . > "$tmp" 2>/dev/null && [ -s "$tmp" ] && mv "$tmp" egregore.json || { rm -f "$tmp"; return 0; }
158
+ SETTINGS_SYNCED_FROM_ORG=$(printf '%s' "$resp" | jq -r '.settings | keys | join(", ")')
159
+ export SETTINGS_SYNCED_FROM_ORG
160
+ # A held posture keeps the revision unapplied so the next start retries.
161
+ if [ "$hold" = "false" ] && [ -f .egregore-state.json ]; then
162
+ tmp=$(mktemp ".egregore-state.json.XXXXXX") || return 0
163
+ jq --arg rev "$revision" '.org_settings_revision = $rev' .egregore-state.json > "$tmp" 2>/dev/null \
164
+ && [ -s "$tmp" ] && mv "$tmp" .egregore-state.json || rm -f "$tmp"
165
+ SETTINGS_CP_SYNCED="true"; export SETTINGS_CP_SYNCED
166
+ fi
167
+ fi
168
+ elif [ -n "$revision" ] && [ "$revision" = "$applied" ] && [ "$local_subset" != "$server_settings" ]; then
169
+ # Server unchanged but local settings moved (offline write, failed push):
170
+ # deliver the local state now. bin/settings.sh owns the push mechanics.
171
+ if bash bin/settings.sh push >/dev/null 2>&1; then
172
+ SETTINGS_CP_SYNCED="true"; export SETTINGS_CP_SYNCED
173
+ fi
174
+ elif [ "$local_subset" = "$server_settings" ]; then
175
+ SETTINGS_CP_SYNCED="true"; export SETTINGS_CP_SYNCED
176
+ fi
177
+ }
178
+
179
+ settings_drift_report() {
180
+ UNSHARED_SETTINGS_KEYS=""
181
+ # Control plane already carries these settings — nothing to ask the user.
182
+ [ "${SETTINGS_CP_SYNCED:-false}" = "true" ] && return 0
183
+ git diff --quiet -- egregore.json 2>/dev/null && return 0
184
+ local tmp_head
185
+ tmp_head=$(mktemp) || return 0
186
+ if ! git show "HEAD:egregore.json" > "$tmp_head" 2>/dev/null; then
187
+ rm -f "$tmp_head"
188
+ return 0
189
+ fi
190
+ UNSHARED_SETTINGS_KEYS=$(jq -rs --argjson keys "$_SETTINGS_OWNED_KEYS" '
191
+ .[0] as $local | .[1] as $head |
192
+ [$keys[] | select(($local[.] // null) != ($head[.] // null))] | join(", ")
193
+ ' egregore.json "$tmp_head" 2>/dev/null || true)
194
+ rm -f "$tmp_head"
195
+ export UNSHARED_SETTINGS_KEYS
196
+ }
@@ -40,12 +40,47 @@ _save() {
40
40
  tmp="$(mktemp "$CONFIG.XXXXXX")" || _die "cannot create temp file" 1
41
41
  if jq "$@" "$filter" "$CONFIG" > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then
42
42
  mv "$tmp" "$CONFIG"
43
+ _control_plane_push
43
44
  else
44
45
  rm -f "$tmp"
45
46
  _die "failed to update $CONFIG (invalid jq filter or unwritable file)" 1
46
47
  fi
47
48
  }
48
49
 
50
+ # ── control-plane push (Connected mode) ──────────────────────────────────
51
+ # A confirmed settings write reaches the control plane immediately — no
52
+ # harness session, no git ceremony in the loop. Fail-soft: the file write
53
+ # already landed, and a missed push is reconciled by the next session
54
+ # start's settings sync (bin/lib/settings-drift.sh).
55
+ _control_plane_push() {
56
+ local api_url api_key payload actor response revision
57
+ api_url=$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)
58
+ [ -n "$api_url" ] || return 0
59
+ api_key=$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2-)
60
+ [ -n "$api_key" ] || return 0
61
+ actor=$(jq -r '.github_username // empty' "$SCRIPT_DIR/.egregore-state.json" 2>/dev/null)
62
+ payload=$(jq -c --arg by "$actor" '{
63
+ settings: ({boundary, base_branch, people_removed, features, repos, admins, people}
64
+ | with_entries(select(.value != null))),
65
+ updated_by: (if $by == "" then null else $by end)
66
+ }' "$CONFIG" 2>/dev/null)
67
+ [ -n "$payload" ] || return 0
68
+ response=$(curl -s --max-time 10 -X PUT "$api_url/api/org/settings" \
69
+ -H "Authorization: Bearer $api_key" -H "Content-Type: application/json" \
70
+ --data-binary "$payload" 2>/dev/null) || return 0
71
+ revision=$(printf '%s' "$response" | jq -r '.settings_revision // empty' 2>/dev/null)
72
+ if [ -n "$revision" ] && [ -f "$SCRIPT_DIR/.egregore-state.json" ]; then
73
+ local st
74
+ st="$(mktemp "$SCRIPT_DIR/.egregore-state.json.XXXXXX")" || return 0
75
+ if jq --arg rev "$revision" '.org_settings_revision = $rev' "$SCRIPT_DIR/.egregore-state.json" > "$st" 2>/dev/null && [ -s "$st" ]; then
76
+ mv "$st" "$SCRIPT_DIR/.egregore-state.json"
77
+ else
78
+ rm -f "$st"
79
+ fi
80
+ fi
81
+ return 0
82
+ }
83
+
49
84
  _has_repo() { jq -e --arg n "$1" 'any(.repos[]?; (if type=="object" then .name else . end) == $n)' "$CONFIG" >/dev/null; }
50
85
  _has_admin() { jq -e --arg h "$1" 'any(.admins[]?; . == $h)' "$CONFIG" >/dev/null; }
51
86
 
@@ -559,6 +594,7 @@ case "$DOMAIN" in
559
594
  people) cmd_people "$@" ;;
560
595
  privacy) cmd_privacy ;;
561
596
  posture) cmd_posture "$@" ;;
597
+ push) _control_plane_push ;;
562
598
  boundary) cmd_boundary "$@" ;;
563
599
  dump) cmd_dump ;;
564
600
  ""|-h|--help|help) usage 0 ;;
@@ -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",
@@ -70,6 +70,19 @@ Preserve supplied wording, order, headings, lists, code, and links. For
70
70
  generated content, use only the useful subset of Briefing, Current State, Open
71
71
  Threads, and Next Steps. Keep it operational and concise.
72
72
 
73
+ ## Staircase (staging)
74
+
75
+ A second face for addressed handoffs exists: the staircase, a
76
+ progressive-disclosure page the receiver walks one step at a time and closes
77
+ with a readback (docs/specs/handoff-staircase.md). It is in staging and does
78
+ not change this skill. When the user asks for a staircase and this instance
79
+ has the staging command (.claude/skills/handoff-staircase/SKILL.md, Codex: the
80
+ handoff-staircase adapter; internal to Curve Labs while in staging), route to
81
+ it: it reuses this preview and approval bridge with a composed spec and adds
82
+ one feedback question at the end. Without it, say the staircase is not
83
+ installed here and continue with this skill. When its feedback
84
+ promotes it, the staircase becomes the default here and that command retires.
85
+
73
86
  ## One-command preview
74
87
 
75
88
  Pipe the exact canonical Markdown once to the private preview bridge. It stores
@@ -26,6 +26,7 @@ NATIVE_SKILLS=(
26
26
  )
27
27
 
28
28
  STRUCTURED_UX_SKILLS=(
29
+ handoff-staircase
29
30
  activity
30
31
  dashboard
31
32
  handoff