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

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 (52) hide show
  1. package/assets/egregore-launcher.js +2 -2
  2. package/bin/cli.js +13 -72
  3. package/lib/setup.js +18 -46
  4. package/package.json +1 -1
  5. package/runtime/claude/.claude/skills/handoff/SKILL.md +13 -0
  6. package/runtime/claude/bin/codex-sync-skills.sh +1 -0
  7. package/runtime/claude/bin/handoff-preview.sh +71 -7
  8. package/runtime/claude/bin/lib/git-sync.sh +15 -0
  9. package/runtime/claude/bin/lib/greeting.sh +6 -0
  10. package/runtime/claude/bin/lib/runtime-owned.sh +30 -0
  11. package/runtime/claude/bin/lib/settings-drift.sh +80 -0
  12. package/runtime/claude/bin/settings.sh +36 -0
  13. package/runtime/claude/egregore_runtime/harness_cli.py +3 -0
  14. package/runtime/claude/egregore_runtime/sync.py +74 -11
  15. package/runtime/claude/egregore_runtime/upgrade.py +200 -58
  16. package/runtime/claude/manifest.json +1 -0
  17. package/runtime/codex/.codex/skills/handoff/SKILL.md +13 -0
  18. package/runtime/codex/bin/codex-sync-skills.sh +1 -0
  19. package/runtime/codex/bin/handoff-preview.sh +71 -7
  20. package/runtime/codex/bin/lib/git-sync.sh +15 -0
  21. package/runtime/codex/bin/lib/greeting.sh +6 -0
  22. package/runtime/codex/bin/lib/runtime-owned.sh +30 -0
  23. package/runtime/codex/bin/lib/settings-drift.sh +80 -0
  24. package/runtime/codex/bin/settings.sh +36 -0
  25. package/runtime/codex/egregore_runtime/harness_cli.py +3 -0
  26. package/runtime/codex/egregore_runtime/sync.py +74 -11
  27. package/runtime/codex/egregore_runtime/upgrade.py +200 -58
  28. package/runtime/codex/manifest.json +1 -0
  29. package/runtime/pi/.codex/skills/handoff/SKILL.md +13 -0
  30. package/runtime/pi/bin/codex-sync-skills.sh +1 -0
  31. package/runtime/pi/bin/handoff-preview.sh +71 -7
  32. package/runtime/pi/bin/lib/git-sync.sh +15 -0
  33. package/runtime/pi/bin/lib/greeting.sh +6 -0
  34. package/runtime/pi/bin/lib/runtime-owned.sh +30 -0
  35. package/runtime/pi/bin/lib/settings-drift.sh +80 -0
  36. package/runtime/pi/bin/settings.sh +36 -0
  37. package/runtime/pi/egregore_runtime/harness_cli.py +3 -0
  38. package/runtime/pi/egregore_runtime/sync.py +74 -11
  39. package/runtime/pi/egregore_runtime/upgrade.py +200 -58
  40. package/runtime/pi/manifest.json +1 -0
  41. package/runtime/prime/.codex/skills/handoff/SKILL.md +13 -0
  42. package/runtime/prime/bin/codex-sync-skills.sh +1 -0
  43. package/runtime/prime/bin/handoff-preview.sh +71 -7
  44. package/runtime/prime/bin/lib/git-sync.sh +15 -0
  45. package/runtime/prime/bin/lib/greeting.sh +6 -0
  46. package/runtime/prime/bin/lib/runtime-owned.sh +30 -0
  47. package/runtime/prime/bin/lib/settings-drift.sh +80 -0
  48. package/runtime/prime/bin/settings.sh +36 -0
  49. package/runtime/prime/egregore_runtime/harness_cli.py +3 -0
  50. package/runtime/prime/egregore_runtime/sync.py +74 -11
  51. package/runtime/prime/egregore_runtime/upgrade.py +200 -58
  52. 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.11
2
+ // egregore-launcher-version: 0.21.0-runtime-mvp.13
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.13";
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/bin/cli.js CHANGED
@@ -25,32 +25,10 @@ const {
25
25
  ghCliExists,
26
26
  tryGhAuthOrLogin,
27
27
  } = require("../lib/auth");
28
- const {
29
- hasCodexRuntimeSupport,
30
- installCodexRuntime,
31
- reportCodexRuntimeSummary,
32
- } = require("../lib/codex-runtime");
33
- const {
34
- hasClaudeRuntimeSupport,
35
- installClaudeRuntime,
36
- reportClaudeRuntimeSummary,
37
- } = require("../lib/claude-runtime");
38
- const {
39
- hasPiRuntimeSupport,
40
- installPiRuntime,
41
- reportPiRuntimeSummary,
42
- } = require("../lib/pi-runtime");
43
- const {
44
- hasPrimeRuntimeSupport,
45
- installPrimeRuntime,
46
- reportPrimeRuntimeSummary,
47
- } = require("../lib/prime-runtime");
48
- const {
49
- connectEligibility,
50
- fetchConnectRuntimePack,
51
- installConnectRuntime,
52
- reportConnectRuntimeSummary,
53
- } = require("../lib/connect-runtime");
28
+ // Runtime adapter installers are deliberately NOT imported here: the global
29
+ // CLI never writes framework/Runtime files into registered instances. Fresh
30
+ // installs get their files in lib/setup.js and lib/local.js; existing
31
+ // instances upgrade only through their own Settings prepare → activate flow.
54
32
  const {
55
33
  install,
56
34
  ghApi,
@@ -125,50 +103,15 @@ function assistantLabel(runtime) {
125
103
  return "Claude Code";
126
104
  }
127
105
 
128
- async function upgradePortableRuntimesForExisting(instances) {
129
- for (const inst of instances) {
130
- if (!inst || !inst.path || !fs.existsSync(inst.path)) continue;
131
- try {
132
- // Fetch and validate the private overlay before pruning the old public
133
- // bundle. During the one-time ownership migration, an API outage must
134
- // leave an existing Connect installation untouched.
135
- const eligibility = connectEligibility(inst.path);
136
- const connectPack = eligibility.eligible
137
- ? await fetchConnectRuntimePack(inst.path)
138
- : null;
139
- const claudeSummary = installClaudeRuntime(inst.path);
140
- const claudeReady = hasClaudeRuntimeSupport(inst.path);
141
- reportClaudeRuntimeSummary(claudeSummary, ui, {
142
- label: `Claude Code runtime for ${inst.slug || inst.name || path.basename(inst.path)}`,
143
- includeConflicts: !claudeReady,
144
- });
145
- const summary = installCodexRuntime(inst.path);
146
- const ready = hasCodexRuntimeSupport(inst.path);
147
- reportCodexRuntimeSummary(summary, ui, {
148
- label: `Codex runtime for ${inst.slug || inst.name || path.basename(inst.path)}`,
149
- includeConflicts: !ready,
150
- });
151
- const piSummary = installPiRuntime(inst.path);
152
- const piReady = hasPiRuntimeSupport(inst.path);
153
- reportPiRuntimeSummary(piSummary, ui, {
154
- label: `Pi meta-harness for ${inst.slug || inst.name || path.basename(inst.path)}`,
155
- includeConflicts: !piReady,
156
- });
157
- const primeSummary = installPrimeRuntime(inst.path);
158
- const primeReady = hasPrimeRuntimeSupport(inst.path);
159
- reportPrimeRuntimeSummary(primeSummary, ui, {
160
- label: `Prime Agent runtime for ${inst.slug || inst.name || path.basename(inst.path)}`,
161
- includeConflicts: !primeReady,
162
- });
163
- const connectSummary = await installConnectRuntime(inst.path, { validatedPack: connectPack });
164
- reportConnectRuntimeSummary(connectSummary, ui, {
165
- label: `Connect features for ${inst.slug || inst.name || path.basename(inst.path)}`,
166
- });
167
- } catch (err) {
168
- ui.warn(`Could not refresh shell-runtime support for ${inst.path}: ${err.message}`);
169
- }
170
- }
171
- }
106
+ // Runtime ownership contract: refreshing the global CLI updates ONLY the
107
+ // global CLI. Registered instances are never installed into, updated,
108
+ // removed from, or reconciled here — an existing instance crosses into the
109
+ // new Runtime exclusively through its own Settings flow
110
+ // (prepare → confirm → activate), which applies engine and all harness
111
+ // adapters transactionally from one verified candidate. A CLI refresh that
112
+ // sprayed adapters into every registered instance is exactly how a legacy
113
+ // instance became an incoherent mixture of old framework and new hooks
114
+ // (its own upstream sync then reverted the tracked half).
172
115
 
173
116
  async function chooseRuntime(args) {
174
117
  if (args.runtime) {
@@ -210,7 +153,6 @@ async function chooseRuntime(args) {
210
153
  }
211
154
 
212
155
  async function upgradeLauncherFromExisting(instances) {
213
- await upgradePortableRuntimesForExisting(instances);
214
156
  const inst = instances.find((entry) => entry && entry.path && fs.existsSync(entry.path));
215
157
  if (!inst) return null;
216
158
  try {
@@ -524,7 +466,6 @@ async function main() {
524
466
  ui.error("No installed Egregore instance was found. Run create-egregore without --refresh-launcher.");
525
467
  process.exit(1);
526
468
  }
527
- await upgradePortableRuntimesForExisting(instances);
528
469
  const alias = await installLauncherBinary(instances[0].path, ui);
529
470
  if (!alias || !alias.binaryPath) {
530
471
  ui.error("Could not refresh the Egregore CLI.");
package/lib/setup.js CHANGED
@@ -577,14 +577,20 @@ async function install(data, ui, targetDir, opts = {}) {
577
577
  try { ensurePersonOnboardingWitness(memoryDir, state); } catch {}
578
578
 
579
579
  // 4c. Overlay the selected harness adapter from the installed npm artifact.
580
- // Each adapter shares the same Egregore Runtime while keeping harness files
581
- // thin and independently refreshable.
580
+ // This is the one-time explicit setup write; launches never install
581
+ // framework files (see the ownership contract at the launch path), and
582
+ // existing instances change only through their Settings
583
+ // prepare → confirm → activate flow — which applies ALL FOUR harness
584
+ // trees from one verified candidate.
582
585
  if (preferredRuntime === "claude-code") {
583
586
  const claudeRuntime = installClaudeRuntime(egregoreDir);
584
587
  reportClaudeRuntimeSummary(claudeRuntime, ui);
585
588
  } else if (preferredRuntime === "codex") {
586
589
  const codexRuntime = installCodexRuntime(egregoreDir);
587
590
  reportCodexRuntimeSummary(codexRuntime, ui);
591
+ // Codex-native skills are framework code, so they render at setup —
592
+ // previously they were sprayed at every launch.
593
+ try { installCodexSkills(egregoreDir); } catch {}
588
594
  } else if (preferredRuntime === "pi") {
589
595
  const piRuntime = installPiRuntime(egregoreDir);
590
596
  reportPiRuntimeSummary(piRuntime, ui);
@@ -1251,50 +1257,16 @@ async function launchOrInstruct(egregoreDir, alias, ui, ctx = {}) {
1251
1257
  ui.info(`Next time, ${launcherNextStep(ui, aliasName)}`);
1252
1258
  console.log("");
1253
1259
 
1254
- if (runtime === "claude-code") {
1255
- const claudeRuntime = installClaudeRuntime(egregoreDir);
1256
- reportClaudeRuntimeSummary(claudeRuntime, ui);
1257
- if (!hasClaudeRuntimeSupport(egregoreDir)) {
1258
- ui.warn("Claude Code runtime support is incomplete; refresh this Egregore before launching.");
1259
- ui.info(`Run ${ui.bold(aliasName)} after updating the checkout.`);
1260
- console.log("");
1261
- return;
1262
- }
1263
- } else if (runtime === "codex") {
1264
- const codexRuntime = installCodexRuntime(egregoreDir);
1265
- reportCodexRuntimeSummary(codexRuntime, ui);
1266
- if (!hasCodexCoreSupport(egregoreDir)) {
1267
- ui.warn(codexSupportMessage(egregoreDir));
1268
- ui.info(`Run ${ui.bold(aliasName)} after updating the checkout.`);
1269
- console.log("");
1270
- return;
1271
- }
1272
- const skillsInstalled = installCodexSkills(egregoreDir);
1273
- const cardRendered = await renderCodexStartupCard(egregoreDir);
1274
- if (!skillsInstalled || !cardRendered) {
1275
- ui.warn(codexSupportMessage(egregoreDir));
1276
- ui.info(`Run ${ui.bold(aliasName)} after updating the checkout.`);
1277
- console.log("");
1278
- return;
1279
- }
1280
- } else if (runtime === "pi") {
1281
- const piRuntime = installPiRuntime(egregoreDir);
1282
- reportPiRuntimeSummary(piRuntime, ui);
1283
- if (!hasPiRuntimeSupport(egregoreDir)) {
1284
- ui.warn(piSupportMessage(egregoreDir));
1285
- ui.info(`Run ${ui.bold(aliasName)} after updating the checkout.`);
1286
- console.log("");
1287
- return;
1288
- }
1289
- } else if (runtime === "prime") {
1290
- const primeRuntime = installPrimeRuntime(egregoreDir);
1291
- reportPrimeRuntimeSummary(primeRuntime, ui);
1292
- if (!hasPrimeRuntimeSupport(egregoreDir)) {
1293
- ui.warn(primeSupportMessage(egregoreDir));
1294
- ui.info(`Run ${ui.bold(aliasName)} after updating the checkout.`);
1295
- console.log("");
1296
- return;
1297
- }
1260
+ // Runtime ownership contract: launching NEVER adds, replaces, or removes
1261
+ // framework/Runtime code. Fresh installs received their files during
1262
+ // explicit setup; existing instances change only through their own
1263
+ // Settings prepare → confirm → activate flow. A legacy instance opens
1264
+ // exactly as it is — old framework, old graph retrieval, no commentary.
1265
+ // The one launch-time write kept below is the Codex startup card, which is
1266
+ // rendered presentation state, not framework code; a legacy instance
1267
+ // without the renderer skips it silently.
1268
+ if (runtime === "codex") {
1269
+ try { await renderCodexStartupCard(egregoreDir); } catch {}
1298
1270
  }
1299
1271
 
1300
1272
  const result = spawnSync(binary, runtimeLaunchArgs(runtime, egregoreDir), {
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.13",
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"
@@ -28,6 +28,10 @@ SYNC_RECEIPT=""
28
28
  # shellcheck source=bin/lib/settings-drift.sh
29
29
  source "$SCRIPT_DIR/bin/lib/settings-drift.sh"
30
30
 
31
+ # Runtime framework ownership (activation record gate for legacy sync).
32
+ # shellcheck source=bin/lib/runtime-owned.sh
33
+ source "$SCRIPT_DIR/bin/lib/runtime-owned.sh"
34
+
31
35
  # The core repo's integration branch: "develop" unless egregore.json sets
32
36
  # base_branch. session-start.sh resolves this before sourcing; resolve it here
33
37
  # too so the lib stands alone for other callers.
@@ -181,6 +185,14 @@ _apply_framework_update() {
181
185
  if [ "$_UPSTREAM_URL" = "none" ] || [ "$_AUTO_UPDATE" != "true" ]; then
182
186
  return 0
183
187
  fi
188
+ # Runtime ownership: once this exact instance activated the new Runtime
189
+ # (activation record with retrieval runtime-qmd), the legacy upstream sync
190
+ # must never overwrite Runtime-owned framework paths — that fight is how a
191
+ # launched-then-synced instance became a broken old/new mixture. Rollback
192
+ # flips the record off runtime-qmd and legacy ownership resumes here.
193
+ if runtime_owns_framework 2>/dev/null; then
194
+ return 0
195
+ fi
184
196
  if ! git show-ref --verify --quiet refs/remotes/upstream/main 2>/dev/null; then
185
197
  return 0
186
198
  fi
@@ -335,6 +347,9 @@ setup_develop() {
335
347
  settings_drift_heal
336
348
  git merge --ff-only "origin/$BASE_BRANCH" --quiet 2>/dev/null || true
337
349
  settings_drift_reapply
350
+ # Connected mode: reconcile with the control plane (org settings row) —
351
+ # pull a teammate's confirmed write down, or re-deliver a failed push.
352
+ settings_drift_pull
338
353
  settings_drift_report
339
354
  fi
340
355
 
@@ -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
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ # runtime-owned.sh — does the Runtime own this instance's framework paths?
3
+ #
4
+ # True exactly when THIS instance has an activation record whose retrieval
5
+ # mode is runtime-qmd. Uses the same stable identity as the Python upgrade
6
+ # store and the graph cutover gate: sha256(org_id | main-checkout path),
7
+ # first 16 hex chars, under EGREGORE_UPGRADE_ROOT (default
8
+ # ~/.egregore/runtime/upgrade). No global flag, no pathname guess, no slug,
9
+ # no branch name, no hardcoded organization or version.
10
+ #
11
+ # After a rollback the record's retrieval flips to previous-runtime, so this
12
+ # returns false again and legacy framework ownership resumes automatically.
13
+ #
14
+ # Contract: sourced with SCRIPT_DIR set to the instance root.
15
+
16
+ runtime_owns_framework() {
17
+ local org main gd key rec
18
+ org=$(jq -r '.org_id // empty' "$SCRIPT_DIR/egregore.json" 2>/dev/null)
19
+ [ -n "$org" ] || return 1
20
+ main="$SCRIPT_DIR"
21
+ if [ -f "$SCRIPT_DIR/.git" ]; then
22
+ gd=$(sed -n 's/^gitdir: //p' "$SCRIPT_DIR/.git" 2>/dev/null)
23
+ case "$gd" in */.git/worktrees/*) main="${gd%/.git/worktrees/*}" ;; esac
24
+ fi
25
+ main=$(cd "$main" 2>/dev/null && pwd -P || printf '%s' "$SCRIPT_DIR")
26
+ key=$(printf '%s|%s' "$org" "$main" | shasum -a 256 2>/dev/null | cut -c1-16)
27
+ [ -n "$key" ] || return 1
28
+ rec="${EGREGORE_UPGRADE_ROOT:-$HOME/.egregore/runtime/upgrade}/${key}/active.json"
29
+ [ -f "$rec" ] && [ "$(jq -r '.retrieval // empty' "$rec" 2>/dev/null)" = "runtime-qmd" ]
30
+ }
@@ -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 ;;
@@ -897,6 +897,9 @@ def _upgrade(args: argparse.Namespace) -> int:
897
897
  state = upgrade_module.describe(store.load())
898
898
  state["active_version"] = _active_runtime_version(root, store)
899
899
  state["instance_hash"] = upgrade_module.instance_key(root)
900
+ # A live apply journal means a previous candidate application died
901
+ # mid-transaction; the next activate recovers deterministically.
902
+ state["interrupted_apply"] = store.load_apply_journal() is not None
900
903
  memory_root = (root / "memory").resolve()
901
904
  if memory_root.is_dir():
902
905
  state["team_sync"] = team_sync(memory_root, fetch=bool(args.fetch)).to_dict()