squadrant 0.9.0 → 0.9.1

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "squadrant",
3
3
  "packageManager": "pnpm@10.30.3",
4
- "version": "0.9.0",
4
+ "version": "0.9.1",
5
5
  "description": "Multi-project orchestration for your coding agents (Claude, Codex, opencode, Gemini)",
6
6
  "type": "module",
7
7
  "bin": {
@@ -2,10 +2,16 @@
2
2
  #
3
3
  # migrate-to-squadrant.sh — one-time live cutover from claude-cockpit → squadrant.
4
4
  #
5
- # Renames the runtime config dir, hub vault, daemon launchd label, and rewrites
6
- # config.json to the new brand. Idempotent (safe to re-run) and supports
7
- # --dry-run (prints every action and a concrete config-rewrite preview WITHOUT
8
- # mutating anything).
5
+ # Renames the runtime config dir, hub vault, this project's spoke vault, the
6
+ # repo folder, the local project key (projects.cockpit -> projects.squadrant),
7
+ # the daemon launchd label, Claude Code's per-project session/memory dirs, and
8
+ # rewrites config.json to the new brand.
9
+ # Idempotent (safe to re-run) and supports --dry-run (prints every action and a
10
+ # concrete config-rewrite preview WITHOUT mutating anything).
11
+ #
12
+ # The repo folder cannot mv itself while it is the running checkout, so Step 0
13
+ # guards that: if invoked from the old repo it prints the exact `mv` to run and
14
+ # exits. Re-run from the new path ($HOME/me/squadrant) to do the full cutover.
9
15
  #
10
16
  # This is run MANUALLY by the user at cutover — it terminates the live captain
11
17
  # session and bounces the daemon. The old daemon keeps running old `dist` until
@@ -24,14 +30,32 @@ case "${1:-}" in
24
30
  * ) echo "usage: $0 [--dry-run]" >&2; exit 2 ;;
25
31
  esac
26
32
 
33
+ # Claude Code munges a project's absolute path into its state-dir name by
34
+ # replacing every '/' and '.' with '-' (e.g. /Users/me/claude-cockpit ->
35
+ # -Users-me-claude-cockpit, and the inner /.worktrees/ -> --worktrees-).
36
+ munge_path() { local p="${1//\//-}"; printf '%s' "${p//./-}"; }
37
+
27
38
  OLD_CONFIG="$HOME/.config/cockpit"
28
39
  NEW_CONFIG="$HOME/.config/squadrant"
29
40
  OLD_HUB="$HOME/cockpit-hub"
30
41
  NEW_HUB="$HOME/squadrant-hub"
42
+ OLD_REPO="$HOME/me/claude-cockpit"
43
+ NEW_REPO="$HOME/me/squadrant"
44
+ # Spoke vault for THIS project, addressed after the hub move (step 3).
45
+ OLD_SPOKE="$NEW_HUB/spokes/cockpit"
46
+ NEW_SPOKE="$NEW_HUB/spokes/squadrant"
31
47
  OLD_LABEL="com.cockpit.daemon"
32
48
  NEW_LABEL="com.squadrant.daemon"
33
49
  OLD_PLIST="$HOME/Library/LaunchAgents/${OLD_LABEL}.plist"
34
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
50
+ # Claude Code's per-project state (session .jsonl transcripts + memory/ auto-memory)
51
+ # is keyed by the munged repo path, so the folder rename orphans it (the new path
52
+ # starts empty). Renaming the state dir prefix re-links it to the renamed repo.
53
+ CLAUDE_PROJECTS="$HOME/.claude/projects"
54
+ OLD_MUNGED="$(munge_path "$OLD_REPO")"
55
+ NEW_MUNGED="$(munge_path "$NEW_REPO")"
56
+ # MIGRATE_REPO_ROOT lets --dry-run simulate running from the renamed repo
57
+ # (to preview the full plan past the Step-0 guard); unset in real runs.
58
+ REPO_ROOT="${MIGRATE_REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
35
59
  UID_NUM="$(id -u)"
36
60
  TS="$(date +%Y%m%d-%H%M%S)"
37
61
  BACKUP="$HOME/squadrant-migration-backup-${TS}.tgz"
@@ -40,8 +64,9 @@ step() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
40
64
  run() { printf ' $ %s\n' "$*"; [ "$DRY_RUN" -eq 1 ] || eval "$@"; }
41
65
  note() { printf ' · %s\n' "$*"; }
42
66
 
43
- # ---- config.json rewrite (pure, no claude-cockpit collisions: none of the
44
- # rules match inside "claude-cockpit", so the real repo path is untouched).
67
+ # ---- config.json rewrite (pure). Rules are scoped so only THIS project's
68
+ # brand strings change: "me/claude-cockpit" + "spokes/cockpit" are unique
69
+ # to the cockpit project, so the other 20+ projects' paths are untouched.
45
70
  rewrite_config() { # $1=src json $2=mode (apply <dest> | preview)
46
71
  local src="$1" mode="$2" dest="${3:-}"
47
72
  SRC="$src" MODE="$mode" DEST="$dest" python3 - <<'PY'
@@ -51,6 +76,8 @@ with open(src) as f:
51
76
  d = json.load(f)
52
77
  RULES = [("cockpit-hub", "squadrant-hub"),
53
78
  (".config/cockpit", ".config/squadrant"),
79
+ ("me/claude-cockpit", "me/squadrant"),
80
+ ("spokes/cockpit", "spokes/squadrant"),
54
81
  ("⚓ cockpit-captain", "⚓ squadrant-captain")]
55
82
  changes = []
56
83
  def fix(s):
@@ -69,6 +96,11 @@ def walk(o):
69
96
  return fix(o)
70
97
  return o
71
98
  d = walk(d)
99
+ # Rename the project key projects.cockpit -> projects.squadrant (preserve order).
100
+ if isinstance(d.get("projects"), dict) and "cockpit" in d["projects"]:
101
+ d["projects"] = {("squadrant" if k == "cockpit" else k): v
102
+ for k, v in d["projects"].items()}
103
+ changes.append(("key:projects.cockpit", "key:projects.squadrant"))
72
104
  if "_cockpitVersion" in d:
73
105
  d["_squadrantVersion"] = d.pop("_cockpitVersion")
74
106
  changes.append(("key:_cockpitVersion", "key:_squadrantVersion"))
@@ -85,10 +117,14 @@ PY
85
117
  }
86
118
 
87
119
  printf '\033[1mSquadrant migration%s\033[0m\n' "$([ "$DRY_RUN" -eq 1 ] && echo ' (DRY RUN — nothing will change)')"
88
- note "repo: $REPO_ROOT"
120
+ note "repo: $OLD_REPO -> $NEW_REPO"
89
121
  note "config: $OLD_CONFIG -> $NEW_CONFIG"
90
122
  note "hub: $OLD_HUB -> $NEW_HUB"
123
+ note "spoke: $OLD_SPOKE -> $NEW_SPOKE"
124
+ note "project key: projects.cockpit -> projects.squadrant"
125
+ note "sessions: $CLAUDE_PROJECTS/$OLD_MUNGED* -> $NEW_MUNGED*"
91
126
  note "daemon: $OLD_LABEL -> $NEW_LABEL"
127
+ note "running from: $REPO_ROOT"
92
128
 
93
129
  # Already migrated? (new dir present, old gone) — nothing to do.
94
130
  if [ -d "$NEW_CONFIG" ] && [ ! -d "$OLD_CONFIG" ]; then
@@ -96,6 +132,31 @@ if [ -d "$NEW_CONFIG" ] && [ ! -d "$OLD_CONFIG" ]; then
96
132
  exit 0
97
133
  fi
98
134
 
135
+ # ---- Step 0: the repo folder must already live at its new name. The script
136
+ # cannot mv its own running checkout, so if we are not in $NEW_REPO we
137
+ # print the exact move + re-run command and stop here.
138
+ step "0. Repo folder rename (must run from the NEW path)"
139
+ if [ "$REPO_ROOT" != "$NEW_REPO" ]; then
140
+ note "running from: $REPO_ROOT"
141
+ note "expected: $NEW_REPO"
142
+ cat <<EOF
143
+
144
+ The repo folder still needs to be renamed, and this script cannot move its own
145
+ running checkout. Do it manually, then re-run from the new location:
146
+
147
+ # 1. close captains (cmux) so nothing holds the old path open
148
+ # 2. move the repo folder:
149
+ mv '$OLD_REPO' '$NEW_REPO'
150
+ # 3. re-run this script from the renamed repo:
151
+ bash '$NEW_REPO/scripts/migrate-to-squadrant.sh'$([ "$DRY_RUN" -eq 1 ] && echo ' --dry-run')
152
+
153
+ (The daemon plist and the global npm link both point at the repo dir; moving it
154
+ first lets steps 6-7 relink and bootstrap from the new location.)
155
+ EOF
156
+ exit 0
157
+ fi
158
+ note "running from $NEW_REPO ✓"
159
+
99
160
  step "1. Backup ~/.config/cockpit and ~/cockpit-hub"
100
161
  # Archive with paths RELATIVE to $HOME so rollback is `tar xzf <backup> -C $HOME`.
101
162
  BACKUP_RELS=()
@@ -112,11 +173,13 @@ step "2. Stop captains + bootout the old daemon"
112
173
  note "Captains are cmux workspaces — close them in cmux, or let the relaunch below recreate them."
113
174
  run "launchctl bootout gui/${UID_NUM}/${OLD_LABEL} 2>/dev/null || true"
114
175
 
115
- step "3. Move config dir and hub vault"
176
+ step "3. Move config dir, hub vault, and this project's spoke vault"
116
177
  if [ -d "$OLD_CONFIG" ] && [ ! -d "$NEW_CONFIG" ]; then run "mv '$OLD_CONFIG' '$NEW_CONFIG'"; else note "config move skipped (old absent or new exists)"; fi
117
178
  if [ -d "$OLD_HUB" ] && [ ! -d "$NEW_HUB" ]; then run "mv '$OLD_HUB' '$NEW_HUB'"; else note "hub move skipped (old absent or new exists)"; fi
179
+ # Spoke lives under the hub; source check covers both pre- and post-hub-move location.
180
+ if { [ -d "$OLD_SPOKE" ] || [ -d "$OLD_HUB/spokes/cockpit" ]; } && [ ! -d "$NEW_SPOKE" ]; then run "mv '$OLD_SPOKE' '$NEW_SPOKE'"; else note "spoke move skipped (source absent or target exists)"; fi
118
181
 
119
- step "4. Rewrite config.json (cockpit-hub→squadrant-hub, .config/cockpit→.config/squadrant, ⚓ cockpit-captain→⚓ squadrant-captain, _cockpitVersion key)"
182
+ step "4. Rewrite config.json (cockpit-hub→squadrant-hub, .config/cockpit→.config/squadrant, me/claude-cockpit→me/squadrant, spokes/cockpit→spokes/squadrant, ⚓ cockpit-captain→⚓ squadrant-captain, projects.cockpit→projects.squadrant key, _cockpitVersion key)"
120
183
  if [ "$DRY_RUN" -eq 1 ]; then
121
184
  CFG_SRC="$OLD_CONFIG/config.json"; [ -f "$CFG_SRC" ] || CFG_SRC="$NEW_CONFIG/config.json"
122
185
  if [ -f "$CFG_SRC" ]; then rewrite_config "$CFG_SRC" preview; else note "no config.json found to preview"; fi
@@ -131,10 +194,69 @@ else
131
194
  fi
132
195
  fi
133
196
 
197
+ step "4.5 Preserve Claude Code session history + auto-memory (rename per-project state dirs)"
198
+ # The main project dir ($OLD_MUNGED) holds the session .jsonl transcripts and the
199
+ # memory/ subdir; its worktree dirs ($OLD_MUNGED--worktrees-*) share the prefix and
200
+ # are orphaned by the SAME folder rename. Swap the prefix on each so the renamed
201
+ # repo finds them. Pure rename — no data is copied or deleted (idempotent: skips
202
+ # any dir whose new-name target already exists).
203
+ if [ -d "$CLAUDE_PROJECTS" ] && [ -n "$OLD_MUNGED" ]; then
204
+ main_dir="$CLAUDE_PROJECTS/$OLD_MUNGED"
205
+ if [ -d "$main_dir" ]; then
206
+ sessions="$(find "$main_dir" -maxdepth 1 -name '*.jsonl' 2>/dev/null | wc -l | tr -d ' ')"
207
+ mem="$([ -d "$main_dir/memory" ] && find "$main_dir/memory" -type f 2>/dev/null | wc -l | tr -d ' ' || echo 0)"
208
+ note "main project dir: $sessions session transcript(s), $mem auto-memory file(s) to preserve"
209
+ fi
210
+ migrated=0; skipped=0
211
+ shopt -s nullglob
212
+ for src in "$CLAUDE_PROJECTS/$OLD_MUNGED" "$CLAUDE_PROJECTS/$OLD_MUNGED-"*; do
213
+ [ -d "$src" ] || continue
214
+ base="$(basename "$src")"
215
+ dest="$CLAUDE_PROJECTS/${NEW_MUNGED}${base#"$OLD_MUNGED"}"
216
+ if [ -e "$dest" ]; then note "skip (target exists): $base"; skipped=$((skipped + 1)); continue; fi
217
+ run "mv '$src' '$dest'"
218
+ migrated=$((migrated + 1))
219
+ done
220
+ shopt -u nullglob
221
+ note "$([ "$DRY_RUN" -eq 1 ] && echo 'would migrate' || echo 'migrated') $migrated state dir(s) (main + worktrees); skipped $skipped"
222
+ else
223
+ note "no $CLAUDE_PROJECTS dir — skipping session/memory preservation"
224
+ fi
225
+
226
+ step "4.6 Rewrite stale 'cockpit crew _hook' commands in Claude Code settings files"
227
+ # The hook-generation source already emits `squadrant crew _hook`, but settings
228
+ # files written on disk BEFORE the rebrand still invoke the removed `cockpit`
229
+ # binary, so every Stop/PostToolUse hook in the captain (and any pre-rebrand crew)
230
+ # fails with "cockpit: command not found". Rewrite the command token in place.
231
+ # Scoped to the leading "cockpit crew _hook" so permission patterns like
232
+ # Bash(cockpit:*) are left untouched. Idempotent (a second run matches nothing).
233
+ HOOK_SETTINGS=(
234
+ "$NEW_REPO/.claude/settings.json"
235
+ "$NEW_REPO/.claude/settings.local.json"
236
+ "$HOME/.claude/settings.json"
237
+ "$HOME/.claude/settings.local.json"
238
+ )
239
+ hooks_fixed=0
240
+ for sf in "${HOOK_SETTINGS[@]}"; do
241
+ [ -f "$sf" ] || continue
242
+ if grep -q 'cockpit crew _hook' "$sf" 2>/dev/null; then
243
+ run "sed -i '' 's/cockpit crew _hook/squadrant crew _hook/g' '$sf'"
244
+ hooks_fixed=$((hooks_fixed + 1))
245
+ else
246
+ note "ok (no stale hook): $sf"
247
+ fi
248
+ done
249
+ note "$([ "$DRY_RUN" -eq 1 ] && echo 'would rewrite' || echo 'rewrote') hook command in $hooks_fixed settings file(s)"
250
+
134
251
  step "5. Remove old launchd plist (the rebuilt daemon installs com.squadrant.daemon.plist on first run)"
135
252
  [ -f "$OLD_PLIST" ] && run "rm -f '$OLD_PLIST'" || note "old plist absent"
136
253
 
137
254
  step "6. Build the rebranded binary + relink the global 'squadrant'/'squad' bin"
255
+ # Reinstall FIRST: the repo-folder rename (Step 0) leaves pnpm's workspace symlinks
256
+ # pointing at the old @cockpit/* package dirs, so a build before `install` aborts
257
+ # with hundreds of unresolved-import errors. `install` regenerates the links for
258
+ # the @squadrant/* packages at their new path; only then can the build resolve them.
259
+ run "pnpm -C '$REPO_ROOT' install"
138
260
  run "pnpm -C '$REPO_ROOT' build"
139
261
  run "pnpm -C '$REPO_ROOT' link --global"
140
262
  note "removes the old global 'cockpit' bin; 'squadrant' and 'squad' now resolve to $REPO_ROOT/dist/index.js"
@@ -150,9 +272,15 @@ cat <<EOF
150
272
  2. Verify CLI: squadrant --version (expect 0.9.0) and squad --help
151
273
  3. Relaunch captains: squadrant launch <project> (recreates the ⚓ squadrant-captain workspaces)
152
274
  4. Tail the log: tail -f ${NEW_CONFIG}/squadrantd.log
275
+ 5. Verify sessions: ls ${CLAUDE_PROJECTS}/${NEW_MUNGED}/ (your history + memory/ should be here)
153
276
  Rollback (if needed):
154
277
  launchctl bootout gui/${UID_NUM}/${NEW_LABEL} 2>/dev/null || true
155
278
  rm -rf ${NEW_CONFIG} ${NEW_HUB}
156
279
  tar xzf ${BACKUP} -C ${HOME}
280
+ mv ${NEW_REPO} ${OLD_REPO} # move the repo folder back
281
+ # session/memory dirs are pure renames — move them back by prefix:
282
+ for d in ${CLAUDE_PROJECTS}/${NEW_MUNGED} ${CLAUDE_PROJECTS}/${NEW_MUNGED}-*; do
283
+ [ -d "\$d" ] && mv "\$d" "${CLAUDE_PROJECTS}/${OLD_MUNGED}\${d#${CLAUDE_PROJECTS}/${NEW_MUNGED}}"
284
+ done
157
285
  # then reinstall the old plist + relink the old 'cockpit' bin
158
286
  EOF
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # remap-claude-mem.sh — unify a claude-mem project slug after a repo rebrand.
4
+ #
5
+ # Usage:
6
+ # scripts/remap-claude-mem.sh [--dry-run] [OLD_SLUG] [NEW_SLUG]
7
+ # scripts/remap-claude-mem.sh --dry-run # preview, mutate nothing
8
+ # scripts/remap-claude-mem.sh # claude-cockpit -> squadrant
9
+ # scripts/remap-claude-mem.sh --dry-run cockpit-old squadrant
10
+ #
11
+ # WHAT IT DOES
12
+ # Makes a claude-mem project's history follow a rebrand so that, when you work
13
+ # in the renamed repo, the old project's memory surfaces under the new slug.
14
+ #
15
+ # Default mapping (after the claude-cockpit -> squadrant rebrand):
16
+ # claude-cockpit -> squadrant (the main project)
17
+ # claude-cockpit/<worktree> -> squadrant/<worktree> (per-worktree subslugs)
18
+ # cwd .../me/claude-cockpit -> .../me/squadrant (pending_messages queue)
19
+ #
20
+ # APPROACH — native `merged_into_project`, NOT a destructive project rewrite
21
+ # claude-mem already ships a "worktree adoption" mechanism that unifies slugs by
22
+ # setting observations.merged_into_project / session_summaries.merged_into_project
23
+ # to the target, and its read path resolves it everywhere:
24
+ # - session-start injection (context-generator) queries SQLite directly with
25
+ # `WHERE (o.project = ? OR o.merged_into_project = ?)`
26
+ # - semantic search (chroma) filters `{$or:[{project},{merged_into_project}]}`
27
+ # So pointing merged_into_project at the new slug surfaces the old rows under it.
28
+ # This is the approach claude-mem itself supports — chosen over rewriting
29
+ # `project` because it is additive, non-destructive, and reversible (the column
30
+ # was NULL before; the .db backup restores the exact prior state).
31
+ #
32
+ # Tables WITHOUT a merge column (sdk_sessions.project, pending_messages.cwd) are
33
+ # session/queue metadata, not searchable memory, and are not vector-stored — for
34
+ # those we rewrite the literal value (the only mechanism available, FTS-safe).
35
+ #
36
+ # FTS5 — no rebuild needed
37
+ # observations_fts / session_summaries_fts / user_prompts_fts index only CONTENT
38
+ # columns (title, narrative, text, ...), NOT project/merged_into_project. We
39
+ # change no FTS-indexed column, and the AFTER UPDATE triggers re-sync FTS from
40
+ # the (unchanged) content automatically. So FTS stays consistent with zero work.
41
+ #
42
+ # CHROMA (vector store) — intentionally NOT touched (see below)
43
+ # claude-mem's chroma runs as an EMBEDDED persistent store
44
+ # (`chroma-mcp --client-type persistent --data-dir ~/.claude-mem/chroma`), held
45
+ # EXCLUSIVELY by the running claude-mem worker. There is no HTTP endpoint. The
46
+ # only supported write path is the worker's own `chroma_update_documents`, which
47
+ # is invoked exclusively during worktree adoption — there is NO claude-mem CLI
48
+ # command to re-attribute existing docs, and a second process writing the
49
+ # persistent store (or raw-editing chroma.sqlite3) risks corruption. We therefore
50
+ # do NOT mutate chroma here.
51
+ #
52
+ # Impact: the SQLite change above fully fixes the PRIMARY surface — the memory
53
+ # injected at session start (which reads SQLite directly). The only gap is that
54
+ # EXPLICIT semantic search (mem-search / smart_search MCP tools) will keep
55
+ # attributing pre-rebrand observations to the OLD slug until their chroma
56
+ # metadata is patched. Safest fallback for that, when it matters:
57
+ # - new post-rebrand observations are searchable under the new slug already;
58
+ # - to re-attribute the historical vectors, ask claude-mem upstream for a
59
+ # re-sync/merge command, or stop the worker and run a one-off
60
+ # chroma_update_documents pass — do NOT edit chroma.sqlite3 by hand.
61
+ #
62
+ # TESTING — never touches the live DB unless you point it there
63
+ # Set CLAUDE_MEM_HOME to a temp dir containing a COPY of claude-mem.db to dry-run
64
+ # and apply against the copy. Make a consistent snapshot copy with:
65
+ # sqlite3 "file:$HOME/.claude-mem/claude-mem.db?immutable=1" \
66
+ # "VACUUM INTO '/tmp/cm-test/claude-mem.db'"
67
+ # CLAUDE_MEM_HOME=/tmp/cm-test scripts/remap-claude-mem.sh --dry-run
68
+ #
69
+ # WHEN TO RUN (live)
70
+ # Run this ONCE, AFTER the repo cutover, while claude-mem is quiet. First confirm
71
+ # the real new slug claude-mem assigns to the renamed repo (run any session in
72
+ # .../me/squadrant and check `project` in a fresh observation), then run this with
73
+ # that slug as NEW_SLUG. Idempotent: safe to re-run; a second run changes 0 rows.
74
+ #
75
+ set -euo pipefail
76
+
77
+ DRY_RUN=0
78
+ POS=()
79
+ for arg in "$@"; do
80
+ case "$arg" in
81
+ --dry-run) DRY_RUN=1 ;;
82
+ -h|--help) sed -n '2,80p' "$0" | sed 's/^#//'; exit 0 ;;
83
+ --*) echo "unknown flag: $arg" >&2; echo "usage: $0 [--dry-run] [OLD_SLUG] [NEW_SLUG]" >&2; exit 2 ;;
84
+ *) POS+=("$arg") ;;
85
+ esac
86
+ done
87
+
88
+ OLD_SLUG="${POS[0]:-claude-cockpit}"
89
+ NEW_SLUG="${POS[1]:-squadrant}"
90
+
91
+ # Slugs are interpolated into SQL; restrict to a safe charset (no quotes/slashes).
92
+ slug_ok() { [[ "$1" =~ ^[A-Za-z0-9._-]+$ ]]; }
93
+ slug_ok "$OLD_SLUG" || { echo "invalid OLD_SLUG: '$OLD_SLUG' (allowed: A-Za-z0-9._-)" >&2; exit 2; }
94
+ slug_ok "$NEW_SLUG" || { echo "invalid NEW_SLUG: '$NEW_SLUG' (allowed: A-Za-z0-9._-)" >&2; exit 2; }
95
+ [ "$OLD_SLUG" = "$NEW_SLUG" ] && { echo "OLD_SLUG and NEW_SLUG are identical — nothing to do." >&2; exit 2; }
96
+
97
+ CLAUDE_MEM_HOME="${CLAUDE_MEM_HOME:-$HOME/.claude-mem}"
98
+ DB="$CLAUDE_MEM_HOME/claude-mem.db"
99
+ # Repo working-copy paths recorded in pending_messages.cwd (overridable for tests).
100
+ OLD_CWD="${OLD_CWD:-$HOME/me/$OLD_SLUG}"
101
+ NEW_CWD="${NEW_CWD:-$HOME/me/$NEW_SLUG}"
102
+ TS="$(date +%Y%m%d-%H%M%S)"
103
+ BACKUP="$DB.bak-rebrand-$TS"
104
+
105
+ step() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
106
+ note() { printf ' · %s\n' "$*"; }
107
+
108
+ command -v sqlite3 >/dev/null || { echo "sqlite3 not found on PATH" >&2; exit 1; }
109
+ [ -f "$DB" ] || { echo "claude-mem DB not found: $DB" >&2; exit 1; }
110
+
111
+ # Read-only count helper (immutable open: never creates/locks WAL on the source).
112
+ roq() { sqlite3 "file:$DB?immutable=1" "$1"; }
113
+
114
+ printf '\033[1mclaude-mem slug remap%s\033[0m\n' "$([ "$DRY_RUN" -eq 1 ] && echo ' (DRY RUN — nothing will change)')"
115
+ note "db: $DB"
116
+ note "old slug: $OLD_SLUG (+ subslugs $OLD_SLUG/<worktree>)"
117
+ note "new slug: $NEW_SLUG"
118
+ note "cwd: $OLD_CWD -> $NEW_CWD"
119
+
120
+ # ---- Per-table counts of rows that WOULD change (same predicates as the UPDATEs).
121
+ OBS_N=$(roq "SELECT COUNT(*) FROM observations WHERE (project='$OLD_SLUG' OR project LIKE '$OLD_SLUG/%' OR merged_into_project='$OLD_SLUG') AND (merged_into_project IS NULL OR merged_into_project<>'$NEW_SLUG');")
122
+ SUM_N=$(roq "SELECT COUNT(*) FROM session_summaries WHERE (project='$OLD_SLUG' OR project LIKE '$OLD_SLUG/%' OR merged_into_project='$OLD_SLUG') AND (merged_into_project IS NULL OR merged_into_project<>'$NEW_SLUG');")
123
+ SDK_N=$(roq "SELECT COUNT(*) FROM sdk_sessions WHERE project='$OLD_SLUG' OR project LIKE '$OLD_SLUG/%';")
124
+ PEND_N=$(roq "SELECT COUNT(*) FROM pending_messages WHERE cwd='$OLD_CWD' OR cwd LIKE '$OLD_CWD/%';")
125
+
126
+ OBS_TOTAL_BEFORE=$(roq "SELECT COUNT(*) FROM observations;")
127
+ RESOLVE_BEFORE=$(roq "SELECT COUNT(*) FROM observations WHERE project='$NEW_SLUG' OR merged_into_project='$NEW_SLUG';")
128
+
129
+ step "Rows that would change"
130
+ note "observations.merged_into_project -> $NEW_SLUG : $OBS_N"
131
+ note "session_summaries.merged_into_project -> $NEW_SLUG : $SUM_N"
132
+ note "sdk_sessions.project rewrite : $SDK_N"
133
+ note "pending_messages.cwd rewrite : $PEND_N"
134
+ note "(observations resolvable under '$NEW_SLUG' now: $RESOLVE_BEFORE of $OBS_TOTAL_BEFORE total)"
135
+
136
+ if [ "$((OBS_N + SUM_N + SDK_N + PEND_N))" -eq 0 ]; then
137
+ step "Nothing to remap — already unified under '$NEW_SLUG' (idempotent no-op)."
138
+ exit 0
139
+ fi
140
+
141
+ if [ "$DRY_RUN" -eq 1 ]; then
142
+ step "Dry run complete — no changes written."
143
+ exit 0
144
+ fi
145
+
146
+ # ---- Backup is mandatory. Refuse to proceed if it fails. (Chroma is not touched,
147
+ # so only the .db is backed up — see header.)
148
+ step "Backup claude-mem.db (required)"
149
+ cp "$DB" "$BACKUP" || { echo "backup failed — aborting, nothing changed" >&2; exit 1; }
150
+ note "backup -> $BACKUP (rollback: cp '$BACKUP' '$DB')"
151
+
152
+ # ---- Apply, single transaction. busy_timeout tolerates a briefly-active worker;
153
+ # still, run this while claude-mem is quiet (see header).
154
+ step "Apply remap (single transaction)"
155
+ sqlite3 "$DB" <<SQL
156
+ .timeout 10000
157
+ BEGIN IMMEDIATE;
158
+
159
+ UPDATE observations
160
+ SET merged_into_project='$NEW_SLUG'
161
+ WHERE (project='$OLD_SLUG' OR project LIKE '$OLD_SLUG/%' OR merged_into_project='$OLD_SLUG')
162
+ AND (merged_into_project IS NULL OR merged_into_project<>'$NEW_SLUG');
163
+
164
+ UPDATE session_summaries
165
+ SET merged_into_project='$NEW_SLUG'
166
+ WHERE (project='$OLD_SLUG' OR project LIKE '$OLD_SLUG/%' OR merged_into_project='$OLD_SLUG')
167
+ AND (merged_into_project IS NULL OR merged_into_project<>'$NEW_SLUG');
168
+
169
+ -- sdk_sessions has no merge column: rewrite the literal project (main + subslugs).
170
+ UPDATE sdk_sessions SET project='$NEW_SLUG' WHERE project='$OLD_SLUG';
171
+ UPDATE sdk_sessions
172
+ SET project='$NEW_SLUG' || substr(project, length('$OLD_SLUG') + 1)
173
+ WHERE project LIKE '$OLD_SLUG/%';
174
+
175
+ -- pending_messages queue: re-point the repo working-copy path.
176
+ UPDATE pending_messages
177
+ SET cwd='$NEW_CWD' || substr(cwd, length('$OLD_CWD') + 1)
178
+ WHERE cwd='$OLD_CWD' OR cwd LIKE '$OLD_CWD/%';
179
+
180
+ COMMIT;
181
+ SQL
182
+ note "transaction committed"
183
+
184
+ # ---- Verify: history now resolves under the new slug and nothing was lost.
185
+ step "Verify"
186
+ OBS_TOTAL_AFTER=$(roq "SELECT COUNT(*) FROM observations;")
187
+ RESOLVE_AFTER=$(roq "SELECT COUNT(*) FROM observations WHERE project='$NEW_SLUG' OR merged_into_project='$NEW_SLUG';")
188
+ OLD_LEFT=$(roq "SELECT COUNT(*) FROM sdk_sessions WHERE project='$OLD_SLUG' OR project LIKE '$OLD_SLUG/%';")
189
+
190
+ note "observations total: $OBS_TOTAL_BEFORE -> $OBS_TOTAL_AFTER"
191
+ note "observations resolvable under '$NEW_SLUG': $RESOLVE_BEFORE -> $RESOLVE_AFTER"
192
+ note "sdk_sessions still on old slug: $OLD_LEFT (expect 0)"
193
+
194
+ # Data loss = the total DECREASED. A live observer can legitimately INSERT new
195
+ # rows mid-remap (we never DELETE), so AFTER > BEFORE is expected and fine; only
196
+ # AFTER < BEFORE means rows were lost. (Strict `-ne` here false-alarmed on that.)
197
+ if [ "$OBS_TOTAL_AFTER" -lt "$OBS_TOTAL_BEFORE" ]; then
198
+ echo "FATAL: observation count dropped ($OBS_TOTAL_BEFORE -> $OBS_TOTAL_AFTER) — DATA LOSS. Restore: cp '$BACKUP' '$DB'" >&2
199
+ exit 1
200
+ fi
201
+ if [ "$OLD_LEFT" -ne 0 ]; then
202
+ echo "WARN: $OLD_LEFT sdk_sessions still on old slug (unexpected)." >&2
203
+ fi
204
+
205
+ step "Done — '$OLD_SLUG' history now resolves under '$NEW_SLUG'."
206
+ note "Re-run is a safe no-op. Chroma semantic search re-attribution: see header (intentionally not modified)."
@@ -1,59 +0,0 @@
1
- #!/usr/bin/env node
2
- // Live E2E for #117: spawnInjector("hidden") must NOT create a split-pane and
3
- // must NOT steal focus. Drives the real built cmux driver against a throwaway
4
- // cmux workspace, then inspects `cmux tree` to assert:
5
- // 1. the workspace has exactly ONE pane (a split would create a second)
6
- // 2. the relay surface exists as a background tab in that pane
7
- // 3. the surface selected before spawn is still [selected] afterward
8
- import { execFileSync } from "node:child_process";
9
- import { fileURLToPath } from "node:url";
10
- import { join } from "node:path";
11
-
12
- const CMUX = "/Applications/cmux.app/Contents/Resources/bin/cmux";
13
- const cmux = (args) => execFileSync(CMUX, args, { encoding: "utf-8" }).trim();
14
- const __dirname = fileURLToPath(new URL(".", import.meta.url));
15
- const { createCmuxDriver } = await import(
16
- "file://" + join(__dirname, "..", "dist", "runtimes", "cmux.js")
17
- );
18
-
19
- let failures = 0;
20
- const assert = (cond, label) => {
21
- console.log(` ${cond ? "✓" : "✗"} ${label}`);
22
- if (!cond) failures++;
23
- };
24
-
25
- const out = cmux(["new-workspace", "--cwd", "/tmp", "--command", "bash"]);
26
- const ws = out.match(/workspace:\d+/)?.[0];
27
- cmux(["rename-workspace", "--workspace", ws, "zz-117-smoke"]);
28
- const driver = createCmuxDriver();
29
-
30
- try {
31
- const treeBefore = cmux(["tree", "--workspace", ws]);
32
- const capSurface = treeBefore.match(/(surface:\d+)\s+\[terminal\][^\n]*\[selected\]/)?.[1];
33
- console.log(`workspace=${ws} captain surface=${capSurface}`);
34
-
35
- const pane = await driver.spawnInjector({
36
- captainWorkspace: { id: ws, name: "zz-117-smoke", status: "running" },
37
- command: "echo notify-relay-stub; sleep 30",
38
- title: "✉ notify-relay",
39
- placement: "hidden",
40
- });
41
- console.log(`spawnInjector returned ${pane.surfaceId}`);
42
-
43
- const tree = cmux(["tree", "--workspace", ws]);
44
- console.log("--- tree after spawnInjector(hidden) ---\n" + tree);
45
-
46
- const paneCount = (tree.match(/^\s*[├└]?[─ ]*pane\s+pane:\d+/gm) || []).length;
47
- assert(paneCount === 1, `exactly ONE pane — no split (got ${paneCount})`);
48
-
49
- const surfaces = (tree.match(/surface:\d+/g) || []);
50
- assert(surfaces.includes(pane.surfaceId), `relay surface ${pane.surfaceId} present as a tab`);
51
-
52
- const selected = tree.match(/(surface:\d+)\s+\[terminal\][^\n]*\[selected\]/)?.[1];
53
- assert(selected === capSurface, `captain surface ${capSurface} still [selected] (relay did NOT steal focus; got ${selected})`);
54
-
55
- console.log(`\n${failures === 0 ? "✔ PLACEMENT SMOKE PASSED" : `✗ ${failures} FAILURES`}`);
56
- } finally {
57
- cmux(["close-workspace", "--workspace", ws]);
58
- process.exit(failures > 0 ? 1 : 0);
59
- }