feat(framework): P4 — upgrade-safe Constitution migration (both installers) (#590)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #590.
This commit is contained in:
2026-06-21 23:03:48 +00:00
committed by jason.woltje
parent 5bef2c35eb
commit bb7d549080
6 changed files with 227 additions and 36 deletions

View File

@@ -21,11 +21,19 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# Files/dirs preserved across upgrades (never overwritten).
# User-created content in these paths survives rsync --delete.
PRESERVE_PATHS=("AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials")
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials")
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
# user must not edit them; a divergent copy is backed up once before overwrite).
# USER_SEEDED files are written once on first install, then owned by the user.
# Both lists are APPEND-FRIENDLY — add a new shipped framework file here and to the
# matching list in packages/mosaic/src/config/file-adapter.ts.
FRAMEWORK_OWNED=("CONSTITUTION.md" "AGENTS.md" "STANDARDS.md")
USER_SEEDED=("TOOLS.md")
# Current framework schema version — bump this when the layout changes.
# The migration system uses this to run upgrade steps.
FRAMEWORK_VERSION=2
FRAMEWORK_VERSION=3
# ─── colours ──────────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
@@ -40,6 +48,45 @@ warn() { echo -e " ${YELLOW}⚠${RESET} $1" >&2; }
fail() { echo -e " ${RED}${RESET} $1" >&2; }
step() { echo -e "\n${BOLD}$1${RESET}"; }
# ─── snapshot / restore (crash safety for upgrades) ──────────────────────────
SNAPSHOT_DIR=""
make_snapshot() {
is_existing_install || return 0
SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-snapshot-XXXXXX")"
cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/" 2>/dev/null || true
}
restore_snapshot() {
[[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] || return 0
fail "Install interrupted/failed — restoring previous state from snapshot"
rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"
cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/" 2>/dev/null || true
}
cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; }
# Reconcile contract files after sync: framework-owned overwrite (backup-once),
# user-seeded seed-if-absent.
reconcile_framework_files() {
local defaults="$TARGET_DIR/defaults" f
[[ -d "$defaults" ]] || return 0
for f in "${FRAMEWORK_OWNED[@]}"; do
[[ -f "$defaults/$f" ]] || continue
if [[ -f "$TARGET_DIR/$f" ]] && ! cmp -s "$TARGET_DIR/$f" "$defaults/$f"; then
if [[ ! -f "$TARGET_DIR/${f}.pre-constitution.bak" ]]; then
cp "$TARGET_DIR/$f" "$TARGET_DIR/${f}.pre-constitution.bak"
warn "$f is now framework-owned and was updated; your previous copy is saved as ${f}.pre-constitution.bak — re-apply intended changes as a .local overlay or policy/ file (see CONSTITUTION.md / constitution/LAYER-MODEL.md)."
fi
fi
cp "$defaults/$f" "$TARGET_DIR/$f"
done
for f in "${USER_SEEDED[@]}"; do
[[ -f "$defaults/$f" ]] || continue
if [[ ! -f "$TARGET_DIR/$f" ]]; then
cp "$defaults/$f" "$TARGET_DIR/$f"
ok "Seeded $f from defaults"
fi
done
}
# ─── helpers ──────────────────────────────────────────────────────────────────
is_existing_install() {
@@ -113,11 +160,14 @@ sync_framework() {
fi
if command -v rsync >/dev/null 2>&1; then
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version")
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak")
if [[ "$INSTALL_MODE" == "keep" ]]; then
# Anchor to the transfer root (leading /) so we preserve the TOP-LEVEL
# ~/.config/mosaic/<file> without also excluding defaults/<file> from sync
# (reconcile_framework_files needs the freshly-synced defaults/ copies).
for path in "${PRESERVE_PATHS[@]}"; do
rsync_args+=(--exclude "$path")
rsync_args+=(--exclude "/$path")
done
fi
@@ -137,7 +187,7 @@ sync_framework() {
done
fi
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" -exec rm -rf {} +
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" -exec rm -rf {} +
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
rm -rf "$TARGET_DIR/.git"
@@ -195,10 +245,15 @@ run_migrations() {
fi
fi
# ── Future migrations go here ──────────────────────────────────────────────
# if [[ "$from_version" -lt 3 ]]; then
# ...
# fi
# ── Migration: v2 → v3 (Constitution split) ───────────────────────────────
# CONSTITUTION.md / AGENTS.md / STANDARDS.md become framework-owned (overwritten
# on upgrade). reconcile_framework_files() has already run before this point: it
# backed up any user-edited copy to <file>.pre-constitution.bak and installed the
# new framework version. Nothing further to do here — the advisory was emitted at
# reconcile time. (STANDARDS.local.md composition lands with the overlay composer.)
if [[ "$from_version" -lt 3 ]]; then
ok "Migrated to the Constitution layout (framework-owned CONSTITUTION/AGENTS/STANDARDS)"
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
@@ -216,6 +271,10 @@ else
ok "Install mode: overwrite"
fi
# Snapshot before any destructive file operation; restore on interrupt/failure.
make_snapshot
trap 'restore_snapshot' ERR INT TERM
sync_framework
# Ensure persistent directories exist
@@ -230,15 +289,7 @@ mkdir -p "$TARGET_DIR/credentials"
# packages/mosaic/src/config/file-adapter.ts (FileConfigAdapter.syncFramework).
# SOUL.md and USER.md are intentionally NOT seeded here — they are generated
# by `mosaic init` from templates with user-supplied values.
DEFAULTS_DIR="$TARGET_DIR/defaults"
if [[ -d "$DEFAULTS_DIR" ]]; then
for default_file in CONSTITUTION.md AGENTS.md STANDARDS.md TOOLS.md; do
if [[ -f "$DEFAULTS_DIR/$default_file" ]] && [[ ! -f "$TARGET_DIR/$default_file" ]]; then
cp "$DEFAULTS_DIR/$default_file" "$TARGET_DIR/$default_file"
ok "Seeded $default_file from defaults"
fi
done
fi
reconcile_framework_files
# Ensure tool scripts are executable
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
@@ -249,6 +300,18 @@ ok "Framework synced to $TARGET_DIR"
# Run migrations before post-install (migrations may remove old bin/ etc.)
run_migrations
# File-system phase complete and consistent — clear the restore trap.
trap - ERR INT TERM
cleanup_snapshot
# Testability / minimal-install hook: stop after the file-system phase, before any
# environment-touching post-install steps (runtime linking, MCP setup, skills, doctor).
if [[ "${MOSAIC_SYNC_ONLY:-0}" == "1" ]]; then
write_framework_version
ok "Sync-only mode: file phase complete"
exit 0
fi
step "Post-install tasks"
SCRIPTS="$TARGET_DIR/tools/_scripts"