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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/assets/egregore-launcher.js +7 -3
  2. package/bin/cli.js +13 -72
  3. package/lib/setup.js +18 -46
  4. package/package.json +1 -1
  5. package/runtime/claude/bin/lib/git-sync.sh +12 -0
  6. package/runtime/claude/bin/lib/greeting.sh +3 -7
  7. package/runtime/claude/bin/lib/runtime-owned.sh +30 -0
  8. package/runtime/claude/bin/lib/settings-drift.sh +5 -1
  9. package/runtime/claude/egregore_runtime/harness_cli.py +3 -0
  10. package/runtime/claude/egregore_runtime/sync.py +74 -11
  11. package/runtime/claude/egregore_runtime/upgrade.py +264 -60
  12. package/runtime/claude/manifest.json +1 -0
  13. package/runtime/codex/bin/lib/git-sync.sh +12 -0
  14. package/runtime/codex/bin/lib/greeting.sh +3 -7
  15. package/runtime/codex/bin/lib/runtime-owned.sh +30 -0
  16. package/runtime/codex/bin/lib/settings-drift.sh +5 -1
  17. package/runtime/codex/egregore_runtime/harness_cli.py +3 -0
  18. package/runtime/codex/egregore_runtime/sync.py +74 -11
  19. package/runtime/codex/egregore_runtime/upgrade.py +264 -60
  20. package/runtime/codex/manifest.json +1 -0
  21. package/runtime/pi/bin/lib/git-sync.sh +12 -0
  22. package/runtime/pi/bin/lib/greeting.sh +3 -7
  23. package/runtime/pi/bin/lib/runtime-owned.sh +30 -0
  24. package/runtime/pi/bin/lib/settings-drift.sh +5 -1
  25. package/runtime/pi/egregore_runtime/harness_cli.py +3 -0
  26. package/runtime/pi/egregore_runtime/sync.py +74 -11
  27. package/runtime/pi/egregore_runtime/upgrade.py +264 -60
  28. package/runtime/pi/manifest.json +1 -0
  29. package/runtime/prime/bin/lib/git-sync.sh +12 -0
  30. package/runtime/prime/bin/lib/greeting.sh +3 -7
  31. package/runtime/prime/bin/lib/runtime-owned.sh +30 -0
  32. package/runtime/prime/bin/lib/settings-drift.sh +5 -1
  33. package/runtime/prime/egregore_runtime/harness_cli.py +3 -0
  34. package/runtime/prime/egregore_runtime/sync.py +74 -11
  35. package/runtime/prime/egregore_runtime/upgrade.py +264 -60
  36. 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.12
2
+ // egregore-launcher-version: 0.21.0-runtime-mvp.14
3
3
  //
4
4
  // egregore — launch and browse Egregore instances from your terminal.
5
5
  // Installed by create-egregore. Reads ~/.egregore/instances.json.
@@ -35,7 +35,7 @@ const crypto = require("node:crypto");
35
35
  const { spawnSync, spawn } = require("node:child_process");
36
36
  const readline = require("node:readline");
37
37
 
38
- const VERSION = "0.21.0-runtime-mvp.12";
38
+ const VERSION = "0.21.0-runtime-mvp.14";
39
39
  const REGISTRY = path.join(os.homedir(), ".egregore", "instances.json");
40
40
  const DEFAULT_FILE = path.join(os.homedir(), ".egregore", "default");
41
41
  const UPDATE_CACHE_FILE = path.join(os.homedir(), ".egregore", "update-check.json");
@@ -2673,7 +2673,11 @@ function formatUpgradeLines(inst, st, { candidate = "", retrieval = null } = {})
2673
2673
  + (d.candidate_version ? ` → candidate ${d.candidate_version}` : candidate ? ` → candidate ${candidate} available` : ""));
2674
2674
  lines.push(`Status: ${UPGRADE_STATUS_LABEL[d.status] || d.status}`);
2675
2675
  if (d.candidate_digest) lines.push(`Package digest: sha256 ${String(d.candidate_digest).slice(0, 16)}…`);
2676
- if (d.failure && d.failure.message) lines.push(`Attention: ${d.failure.message}`);
2676
+ // A recorded failure only needs attention while the transaction is stuck
2677
+ // on it; once a later prepare succeeds the line would contradict "ready".
2678
+ if (d.failure && d.failure.message && (d.status === "failed" || d.status === "interrupted")) {
2679
+ lines.push(`Attention: ${d.failure.message}`);
2680
+ }
2677
2681
  if (d.rollback_version && d.status !== "none") lines.push(`Rollback target: ${d.rollback_version}`);
2678
2682
  const ts = d.team_sync;
2679
2683
  if (ts && ts.label) lines.push(`Team memory: ${ts.label}`);
package/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.12",
3
+ "version": "0.21.0-runtime-mvp.14",
4
4
  "description": "Set up Egregore for your team in one command — installs the egregore launcher and your choice of Claude Code, Codex, or Pi.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -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
@@ -95,19 +95,15 @@ if [ -n "$SAVED_BRANCH" ]; then
95
95
  echo " ✓ Auto-saved uncommitted work on $SAVED_BRANCH"
96
96
  fi
97
97
 
98
- # --- Unshared org-settings drift (launcher writes not yet shared) ---
99
- if [ "${SETTINGS_ADAPTER_RESTORED:-false}" = "true" ]; then
100
- echo " ◇ restored org .claude/settings.json — a generated adapter had replaced it locally"
101
- fi
98
+ # --- Org-settings sync receipts. A settings.json heal is logged to
99
+ # .egregore/settings-drift.log, never announced; local settings changes reach
100
+ # the control plane through bin/settings.sh, so there is nothing to ask. ---
102
101
  if [ -n "${SETTINGS_SYNCED_FROM_ORG:-}" ]; then
103
102
  echo " ◇ org settings synced from the control plane (${SETTINGS_SYNCED_FROM_ORG})"
104
103
  fi
105
104
  if [ -n "${SETTINGS_POSTURE_HELD:-}" ]; then
106
105
  echo " ◇ boundary posture was set to ${SETTINGS_POSTURE_HELD} elsewhere — relaxing access needs your confirm: bash bin/settings.sh posture ${SETTINGS_POSTURE_HELD}"
107
106
  fi
108
- if [ -n "${UNSHARED_SETTINGS_KEYS:-}" ]; then
109
- echo " ◇ org settings changed on this machine (${UNSHARED_SETTINGS_KEYS}) — say \"share settings\" to make them org-wide"
110
- fi
111
107
 
112
108
  # --- Footer: health + repos + memory (tertiary) ---
113
109
  echo "$SEPARATOR"
@@ -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
+ }
@@ -61,7 +61,11 @@ settings_drift_heal() {
61
61
 
62
62
  if ! git diff --quiet -- .claude/settings.json 2>/dev/null; then
63
63
  if _settings_is_reduced_adapter; then
64
- git checkout -- .claude/settings.json 2>/dev/null && SETTINGS_ADAPTER_RESTORED="true"
64
+ if git checkout -- .claude/settings.json 2>/dev/null; then
65
+ SETTINGS_ADAPTER_RESTORED="true"
66
+ mkdir -p .egregore 2>/dev/null && printf '%s restored .claude/settings.json: a generated adapter had replaced the org file\n' \
67
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> .egregore/settings-drift.log 2>/dev/null || true
68
+ fi
65
69
  fi
66
70
  fi
67
71
 
@@ -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()
@@ -170,14 +170,80 @@ class LocalGitSyncTransport:
170
170
  remote_is_ancestor = self._run(
171
171
  "merge-base", "--is-ancestor", remote_revision, "HEAD"
172
172
  ).returncode == 0
173
+ warnings: list[str] = []
173
174
  if not remote_is_ancestor:
174
- if self._required("status", "--porcelain"):
175
- raise GitSyncError(
176
- "canonical Git repository has incoming changes and local "
177
- "working-tree edits; commit or resolve them before sync"
178
- )
179
- self._required("rebase", remote_revision, timeout=180)
180
- return self._status(changed=self._revision() != before)
175
+ warnings.extend(self._integrate_remote(remote_revision))
176
+ return self._status(tuple(warnings), changed=self._revision() != before)
177
+
178
+ def _integrate_remote(self, remote_revision: str) -> list[str]:
179
+ """Integrate fetched history into a possibly-dirty canonical checkout.
180
+
181
+ Canonical memory has many concurrent writers (sessions, background
182
+ capture, hosted agents) appending distinct files — incoming history
183
+ plus local edits is almost never a content conflict, so it must
184
+ self-heal instead of failing closed. The rebase autostashes local
185
+ edits and re-applies them; edits git cannot re-apply are parked in
186
+ the stash (content preserved, surfaced as a warning). Only a rebase
187
+ that itself conflicts — a genuine same-line collision with local
188
+ commits — fails closed.
189
+ """
190
+ warnings: list[str] = []
191
+ dirty = bool(self._required("status", "--porcelain"))
192
+ stashes_before = self._run("stash", "list").stdout.count("\n")
193
+ arguments = ["rebase"]
194
+ if dirty:
195
+ arguments.append("--autostash")
196
+ result = self._run(*arguments, remote_revision, timeout=180)
197
+ if result.returncode != 0:
198
+ self._run("rebase", "--abort")
199
+ detail = (result.stderr or result.stdout).strip().splitlines()
200
+ raise GitSyncError(
201
+ "canonical sync hit a real content conflict while integrating "
202
+ "incoming changes; resolve it in the memory repository"
203
+ + (f" ({detail[-1]})" if detail else "")
204
+ )
205
+ if dirty and self._run("stash", "list").stdout.count("\n") > stashes_before:
206
+ # Git kept the whole autostash entry AND left a conflicted
207
+ # application in the tree. Every local edit is safe inside the
208
+ # kept stash, so the tree resets to the clean rebased HEAD —
209
+ # parked edits, never conflict markers in canonical files.
210
+ self._run("reset", "--hard", "HEAD")
211
+ warnings.append(
212
+ "local memory edits could not be re-applied over incoming "
213
+ "changes and were parked in git stash — recover with "
214
+ "`git stash pop` in the memory repository"
215
+ )
216
+ return warnings
217
+
218
+ def _push_with_retry(self, attempts: int = 3) -> list[str]:
219
+ """Push canonical history, absorbing the many-writer race.
220
+
221
+ The memory remote moves constantly (teammates' sessions, background
222
+ capture, hosted agents). A rejected push is re-integrated with an
223
+ autostashed rebase and retried — the exact manual recovery this
224
+ failure class always needed — before anything is reported as failed.
225
+ """
226
+ detail = ""
227
+ for attempt in range(attempts):
228
+ pushed = self._run("push", "origin", "HEAD", timeout=180)
229
+ if pushed.returncode == 0:
230
+ return []
231
+ detail = (pushed.stderr or pushed.stdout).strip()
232
+ if attempt == attempts - 1:
233
+ break
234
+ branch = self._required("branch", "--show-current")
235
+ if not branch:
236
+ break
237
+ if self._run("fetch", "origin", branch, timeout=180).returncode != 0:
238
+ break
239
+ remote_revision = self._remote_revision()
240
+ if remote_revision is None:
241
+ break
242
+ try:
243
+ self._integrate_remote(remote_revision)
244
+ except GitSyncError as exc:
245
+ return [f"remote push failed: {exc}"]
246
+ return [f"remote push failed: {detail or 'git push failed'}"]
181
247
 
182
248
  def push(self, *, message: str, paths: Sequence[str] = ()) -> SyncStatus:
183
249
  selected = self._relative_paths(paths)
@@ -209,10 +275,7 @@ class LocalGitSyncTransport:
209
275
  if warning := self._push_remote_background():
210
276
  warnings.append(warning)
211
277
  else:
212
- pushed = self._run("push", "origin", "HEAD", timeout=180)
213
- if pushed.returncode != 0:
214
- detail = (pushed.stderr or pushed.stdout).strip()
215
- warnings.append(f"remote push failed: {detail or 'git push failed'}")
278
+ warnings.extend(self._push_with_retry())
216
279
  else:
217
280
  warnings.append("no origin remote configured; canonical history is local")
218
281
  return self._status(warnings)