create-egregore 0.21.0-runtime-mvp.11 → 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 (30) hide show
  1. package/assets/egregore-launcher.js +2 -2
  2. package/package.json +1 -1
  3. package/runtime/claude/.claude/skills/handoff/SKILL.md +13 -0
  4. package/runtime/claude/bin/codex-sync-skills.sh +1 -0
  5. package/runtime/claude/bin/handoff-preview.sh +71 -7
  6. package/runtime/claude/bin/lib/git-sync.sh +3 -0
  7. package/runtime/claude/bin/lib/greeting.sh +6 -0
  8. package/runtime/claude/bin/lib/settings-drift.sh +80 -0
  9. package/runtime/claude/bin/settings.sh +36 -0
  10. package/runtime/codex/.codex/skills/handoff/SKILL.md +13 -0
  11. package/runtime/codex/bin/codex-sync-skills.sh +1 -0
  12. package/runtime/codex/bin/handoff-preview.sh +71 -7
  13. package/runtime/codex/bin/lib/git-sync.sh +3 -0
  14. package/runtime/codex/bin/lib/greeting.sh +6 -0
  15. package/runtime/codex/bin/lib/settings-drift.sh +80 -0
  16. package/runtime/codex/bin/settings.sh +36 -0
  17. package/runtime/pi/.codex/skills/handoff/SKILL.md +13 -0
  18. package/runtime/pi/bin/codex-sync-skills.sh +1 -0
  19. package/runtime/pi/bin/handoff-preview.sh +71 -7
  20. package/runtime/pi/bin/lib/git-sync.sh +3 -0
  21. package/runtime/pi/bin/lib/greeting.sh +6 -0
  22. package/runtime/pi/bin/lib/settings-drift.sh +80 -0
  23. package/runtime/pi/bin/settings.sh +36 -0
  24. package/runtime/prime/.codex/skills/handoff/SKILL.md +13 -0
  25. package/runtime/prime/bin/codex-sync-skills.sh +1 -0
  26. package/runtime/prime/bin/handoff-preview.sh +71 -7
  27. package/runtime/prime/bin/lib/git-sync.sh +3 -0
  28. package/runtime/prime/bin/lib/greeting.sh +6 -0
  29. package/runtime/prime/bin/lib/settings-drift.sh +80 -0
  30. package/runtime/prime/bin/settings.sh +36 -0
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // egregore-launcher-version: 0.21.0-runtime-mvp.11
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.11";
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.21.0-runtime-mvp.11",
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"
@@ -335,6 +335,9 @@ setup_develop() {
335
335
  settings_drift_heal
336
336
  git merge --ff-only "origin/$BASE_BRANCH" --quiet 2>/dev/null || true
337
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
338
341
  settings_drift_report
339
342
  fi
340
343
 
@@ -99,6 +99,12 @@ fi
99
99
  if [ "${SETTINGS_ADAPTER_RESTORED:-false}" = "true" ]; then
100
100
  echo " ◇ restored org .claude/settings.json — a generated adapter had replaced it locally"
101
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
102
108
  if [ -n "${UNSHARED_SETTINGS_KEYS:-}" ]; then
103
109
  echo " ◇ org settings changed on this machine (${UNSHARED_SETTINGS_KEYS}) — say \"share settings\" to make them org-wide"
104
110
  fi
@@ -98,8 +98,88 @@ settings_drift_reapply() {
98
98
  _SETTINGS_DRIFT_BASE=""
99
99
  }
100
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
+
101
179
  settings_drift_report() {
102
180
  UNSHARED_SETTINGS_KEYS=""
181
+ # Control plane already carries these settings — nothing to ask the user.
182
+ [ "${SETTINGS_CP_SYNCED:-false}" = "true" ] && return 0
103
183
  git diff --quiet -- egregore.json 2>/dev/null && return 0
104
184
  local tmp_head
105
185
  tmp_head=$(mktemp) || return 0
@@ -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 ;;
@@ -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
@@ -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"
@@ -335,6 +335,9 @@ setup_develop() {
335
335
  settings_drift_heal
336
336
  git merge --ff-only "origin/$BASE_BRANCH" --quiet 2>/dev/null || true
337
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
338
341
  settings_drift_report
339
342
  fi
340
343
 
@@ -99,6 +99,12 @@ fi
99
99
  if [ "${SETTINGS_ADAPTER_RESTORED:-false}" = "true" ]; then
100
100
  echo " ◇ restored org .claude/settings.json — a generated adapter had replaced it locally"
101
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
102
108
  if [ -n "${UNSHARED_SETTINGS_KEYS:-}" ]; then
103
109
  echo " ◇ org settings changed on this machine (${UNSHARED_SETTINGS_KEYS}) — say \"share settings\" to make them org-wide"
104
110
  fi