From c828d1061c7f120af62cdc383711922e49f90eee Mon Sep 17 00:00:00 2001 From: aaron Date: Sat, 8 Aug 2026 22:51:45 -0400 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20armonia=20workspace=20setup=20scrip?= =?UTF-8?q?ts=20=E2=80=94=20bootstrap=20+=20migration=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools/bootstrap-armonia.sh: fresh-machine canonical layout with clone tiers (--minimal|--standard|--full), curl-able, idempotent - tools/migrate-to-armonia.sh: three-bucket repos routing (packages/, demos/, flat), bucket-converging on re-run --- tools/bootstrap-armonia.sh | 171 ++++++++++++++++++++++++++ tools/migrate-to-armonia.sh | 237 ++++++++++++++++++++++++++++++++++++ 2 files changed, 408 insertions(+) create mode 100755 tools/bootstrap-armonia.sh create mode 100755 tools/migrate-to-armonia.sh diff --git a/tools/bootstrap-armonia.sh b/tools/bootstrap-armonia.sh new file mode 100755 index 00000000..b9b75ea2 --- /dev/null +++ b/tools/bootstrap-armonia.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +set -euo pipefail + +# bootstrap-armonia.sh +# Fresh-machine setup for the canonical Amicode workspace layout. +# Any user — Harmoniqs dev or external contributor — gets the same tree: +# +# ~/armonia/repos/packages/ Julia libraries (Piccolo.jl, …) +# ~/armonia/repos/demos/ demo galleries (atoms-demo, …) +# ~/armonia/repos/ apps, forks, projects (amicode, …) +# ~/armonia/data/{env,problems,runs,vaults} +# +# Usage: +# bootstrap-armonia.sh [--minimal|--standard|--full] +# +# --minimal layout + data dirs only (extension users who never touch source) +# --standard + public packages and demos via plain git clone (default) +# --full + private Harmoniqs repos via `gh` (requires gh auth with access) +# +# Curl-able for users who have not cloned anything: +# bash <(curl -fsSL https://raw.githubusercontent.com/harmoniqs/amicode/main/tools/bootstrap-armonia.sh) +# +# Idempotent: existing clones are skipped (git pull --ff-only is attempted), +# existing symlinks are left alone. + +ARMONIA="${HOME}/armonia" +AMICO="${HOME}/.amico" +TIER="standard" + +for arg in "$@"; do + case "$arg" in + --minimal|--standard|--full) TIER="${arg#--}" ;; + -h|--help) + sed -n '2,22p' "$0"; exit 0 ;; + *) echo "unknown arg: $arg" >&2; exit 64 ;; + esac +done + +# Public Julia libraries (registered packages; plain git clone works). +PUBLIC_PACKAGES=( + Piccolo.jl + NamedTrajectories.jl + DirectTrajOpt.jl +) + +# Public demo galleries. +PUBLIC_DEMOS=( + atoms-demo + fluxonium-demo + ions +) + +# Private Harmoniqs repos (cloned with gh; requires org access). +PRIVATE_PACKAGES=( + Piccolissimo.jl +) +PRIVATE_APPS=( + amicode +) + +# =================================================================== +main() { + echo "==> bootstrap-armonia tier=${TIER}" + echo + + make_layout + wire_amico_links + + case "$TIER" in + minimal) echo "tier=minimal — no repos cloned" ;; + standard) clone_public ;; + full) clone_public; clone_private ;; + esac + + echo + echo "==> Done." + echo " Layout: ~/armonia/{repos/{packages,demos,...}, data/{env,problems,runs,vaults}}" + echo " Open it: open ~/armonia/ (or add ~/armonia to your VS Code workspace)" +} + +# ------------------------------------------------------------------- +make_layout() { + echo "--- layout ---" + mkdir -p "${ARMONIA}/repos/packages" "${ARMONIA}/repos/demos" \ + "${ARMONIA}/data/env" "${ARMONIA}/data/problems" \ + "${ARMONIA}/data/runs" "${ARMONIA}/data/vaults" + echo " ~/armonia/{repos/{packages,demos}, data/{env,problems,runs,vaults}} ready" +} + +# ------------------------------------------------------------------- +# ~/.amico/ → ~/armonia/data/, created only when safe. +# If ~/.amico/ already exists as a REAL directory with content, that is +# the migration case — point the user at migrate-to-armonia.sh instead of +# clobbering it. +wire_amico_links() { + echo "--- ~/.amico links ---" + local pairs=("julia:env" "problems:problems" "runs:runs" "vaults:vaults") + for pair in "${pairs[@]}"; do + local name="${pair%%:*}" target="${pair##*:}" + local src="${AMICO}/${name}" dest="${ARMONIA}/data/${target}" + mkdir -p "$AMICO" + if [[ -L "$src" ]]; then + echo " (symlink) ~/.amico/${name}" + elif [[ -d "$src" && -n "$(ls -A "$src" 2>/dev/null)" ]]; then + echo " (real dir, not empty) ~/.amico/${name} — run tools/migrate-to-armonia.sh first" + elif [[ -d "$src" ]]; then + rmdir "$src" && ln -s "$dest" "$src" + echo " linked ~/.amico/${name} → data/${target}" + else + ln -s "$dest" "$src" + echo " linked ~/.amico/${name} → data/${target}" + fi + done +} + +# ------------------------------------------------------------------- +clone_public() { + echo "--- clone (public) ---" + for repo in "${PUBLIC_PACKAGES[@]}"; do + clone_or_update "https://github.com/harmoniqs/${repo}.git" "${ARMONIA}/repos/packages/${repo}" + done + for repo in "${PUBLIC_DEMOS[@]}"; do + clone_or_update "https://github.com/harmoniqs/${repo}.git" "${ARMONIA}/repos/demos/${repo}" + done +} + +# ------------------------------------------------------------------- +clone_private() { + echo "--- clone (private, via gh) ---" + if ! command -v gh >/dev/null 2>&1; then + echo " gh not installed — skipping private tier"; return 0 + fi + if ! gh auth status >/dev/null 2>&1; then + echo " gh not authenticated — skipping private tier (run: gh auth login)"; return 0 + fi + for repo in "${PRIVATE_PACKAGES[@]}"; do + gh_clone_or_update "harmoniqs/${repo}" "${ARMONIA}/repos/packages/${repo}" + done + for repo in "${PRIVATE_APPS[@]}"; do + gh_clone_or_update "harmoniqs/${repo}" "${ARMONIA}/repos/${repo}" + done +} + +# ------------------------------------------------------------------- +clone_or_update() { + local url="$1" dest="$2" + if [[ -d "${dest}/.git" ]]; then + echo " (exists) $(basename "$dest") — pulling" + git -C "$dest" pull --ff-only 2>/dev/null || echo " (pull skipped: not fast-forwardable)" + elif [[ -e "$dest" ]]; then + echo " (exists, not a git repo — left alone) $(basename "$dest")" + else + echo " clone $(basename "$dest")" + git clone "$url" "$dest" + fi +} + +gh_clone_or_update() { + local repo="$1" dest="$2" + if [[ -d "${dest}/.git" ]]; then + echo " (exists) $(basename "$dest") — pulling" + git -C "$dest" pull --ff-only 2>/dev/null || echo " (pull skipped: not fast-forwardable)" + elif [[ -e "$dest" ]]; then + echo " (exists, not a git repo — left alone) $(basename "$dest")" + else + echo " clone $repo" + gh repo clone "$repo" "$dest" + fi +} + +main diff --git a/tools/migrate-to-armonia.sh b/tools/migrate-to-armonia.sh new file mode 100755 index 00000000..029c5636 --- /dev/null +++ b/tools/migrate-to-armonia.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +set -euo pipefail + +# migrate-to-armonia.sh +# Idempotent migration: consolidate repos + Amico data into ~/armonia/. +# Safe to run multiple times — skips what is already in place, and converges +# the repos/ buckets on re-run (flat .jl packages → packages/, demo dirs → demos/). +# +# Canonical layout: +# ~/armonia/repos/packages/ Julia libraries (Piccolo.jl, …) +# ~/armonia/repos/demos/ demo galleries (atoms-demo, …) +# ~/armonia/repos/ apps, forks, research projects (amicode, passaggio, …) +# ~/armonia/data/{env,problems,runs,vaults} + +ARMONIA="${HOME}/armonia" +AMICO="${HOME}/.amico" + +# ---- discover source repos ---- +# Directories that might hold git checkouts to move. +REPO_SOURCES=( + "${HOME}/_dev/harmoniqs" + "${HOME}/harmoniqs" + "${HOME}/AmicodeProjects" + "${HOME}/_dev" +) + +# ---- discover data dirs to migrate ---- +# Each entry: "amico_dir armonia_target" +DATA_DIRS=( + "julia env" + "problems problems" + "runs runs" + "vaults vaults" +) + +# Known demo repo names → routed to repos/demos/. A source dir literally named +# "demos" is moved as-is (its contents are already grouped). +is_demo() { [[ "$1" == *demo* || "$1" == "atoms" || "$1" == "fluxonium" || "$1" == "ions" ]]; } + +# Julia packages route to repos/packages/ by the .jl suffix convention. +is_package() { [[ "$1" == *.jl || "$1" == *.jl-* ]]; } + +# =================================================================== +main() { + echo "==> migrate-to-armonia (idempotent)" + echo + + mkdir -p "${ARMONIA}/repos/packages" "${ARMONIA}/repos/demos" \ + "${ARMONIA}/data/env" "${ARMONIA}/data/problems" \ + "${ARMONIA}/data/runs" "${ARMONIA}/data/vaults" + + converge_buckets + migrate_repos + migrate_data + cleanup_empty_parents + + echo + echo "==> Done." + echo " Run: open ~/armonia/" +} + +# ------------------------------------------------------------------- +# Re-run convergence: repos/ that already migrated flat get bucketed. +converge_buckets() { + local moved=0 + for child in "${ARMONIA}/repos"/*/; do + [[ -d "$child" ]] || continue + local name; name=$(basename "$child") + case "$name" in packages|demos) continue ;; esac + if is_package "$name"; then + echo " bucket: repos/$name → repos/packages/$name" + mv "$child" "${ARMONIA}/repos/packages/$name" + moved=1 + elif is_demo "$name"; then + echo " bucket: repos/$name → repos/demos/$name" + mv "$child" "${ARMONIA}/repos/demos/$name" + moved=1 + fi + done + # A shared Julia dev env (Project.toml/Manifest.toml) stranded at repos/ + # belongs with the packages it references. + for f in Project.toml Manifest.toml; do + if [[ -f "${ARMONIA}/repos/$f" && ! -f "${ARMONIA}/repos/packages/$f" ]]; then + echo " bucket: repos/$f → repos/packages/$f" + mv "${ARMONIA}/repos/$f" "${ARMONIA}/repos/packages/$f" + fi + done + [[ $moved -eq 1 ]] && echo + return 0 +} + +# ------------------------------------------------------------------- +migrate_repos() { + echo "--- repos ---" + + for src_dir in "${REPO_SOURCES[@]}"; do + if [[ ! -d "$src_dir" ]]; then + echo " (skip) not found: $src_dir" + continue + fi + local src_children + src_children=$(find "$src_dir" -mindepth 1 -maxdepth 1 ! -name 'node_modules' 2>/dev/null || true) + if [[ -z "$src_children" ]]; then + echo " (skip) empty: $src_dir" + continue + fi + echo " source: $src_dir" + while IFS= read -r child; do + [[ -z "$child" ]] && continue + local name + name=$(basename "$child") + + # Shell scripts and loose files stay; only directories move. A root-level + # Project.toml/Manifest.toml accompanies the packages. + if [[ ! -d "$child" ]]; then + case "$name" in + Project.toml|Manifest.toml) + if [[ ! -f "${ARMONIA}/repos/packages/$name" ]]; then + echo " mv $name → packages/" + mv "$child" "${ARMONIA}/repos/packages/$name" + fi + ;; + *) echo " (skip file) $name" ;; + esac + continue + fi + + # Route to the right bucket. + local bucket="${ARMONIA}/repos" + if is_package "$name"; then + bucket="${ARMONIA}/repos/packages" + elif is_demo "$name" || [[ "$name" == "demos" ]]; then + bucket="${ARMONIA}/repos/demos" + fi + # A "demos" source dir lands AS repos/demos (contents grouped inside); + # merging into it rather than nesting demos/demos. + local dest + if [[ "$name" == "demos" ]]; then + dest="$bucket" + else + dest="${bucket}/${name}" + fi + + if [[ -e "$dest" && "$name" != "demos" ]]; then + echo " (exists) $name" + continue + fi + if [[ "$name" == "demos" && -d "$dest" ]]; then + # merge contents into the existing demos bucket + local demo_children + demo_children=$(find "$child" -mindepth 1 -maxdepth 1 2>/dev/null || true) + while IFS= read -r d; do + [[ -z "$d" ]] && continue + local dname; dname=$(basename "$d") + if [[ -e "${dest}/${dname}" ]]; then + echo " (exists) demos/$dname" + else + echo " mv demos/$dname" + mv "$d" "${dest}/${dname}" + fi + done <<< "$demo_children" + continue + fi + + echo " mv $name → ${bucket#"$ARMONIA"/}" + mv "$child" "$dest" + done <<< "$src_children" + done +} + +# ------------------------------------------------------------------- +migrate_data() { + echo "--- data ---" + + for entry in "${DATA_DIRS[@]}"; do + read -r amico_name armonia_name <<< "$entry" + local src="${AMICO}/${amico_name}" + local dest="${ARMONIA}/data/${armonia_name}" + + # already a symlink → done + if [[ -L "$src" ]]; then + echo " (symlink) ~/.amico/${amico_name}" + continue + fi + + # dest already populated → assume already migrated + if [[ -d "$dest" && -n "$(ls -A "$dest" 2>/dev/null)" ]]; then + # source still a real dir → just symlink it + if [[ -d "$src" && ! -L "$src" ]]; then + echo " (dest exists) ~/.amico/${amico_name} → symlink" + rm -rf "$src" + ln -s "$dest" "$src" + else + echo " (ok) ~/.amico/${amico_name}" + fi + continue + fi + + # source is a real dir, dest does not exist → move + symlink + if [[ -d "$src" && ! -L "$src" ]]; then + echo " mv ~/.amico/${amico_name} → data/${armonia_name}" + mv "$src" "$dest" + ln -s "$dest" "$src" + else + echo " (skip) ~/.amico/${amico_name} does not exist" + fi + done +} + +# ------------------------------------------------------------------- +cleanup_empty_parents() { + echo "--- cleanup ---" + for src_dir in "${REPO_SOURCES[@]}"; do + # never remove HOME or root-level dirs + case "$src_dir" in + "$HOME"|"$HOME/Desktop"|"$HOME/Documents"|"$HOME/Downloads") continue ;; + esac + if [[ -d "$src_dir" ]]; then + local remaining + remaining=$(find "$src_dir" -mindepth 1 -maxdepth 1 2>/dev/null || true) + if [[ -z "$remaining" ]]; then + echo " rmdir $src_dir" + rmdir "$src_dir" + # try to remove the parent if it is now empty + local parent + parent=$(dirname "$src_dir") + local parent_remaining + parent_remaining=$(find "$parent" -mindepth 1 -maxdepth 1 2>/dev/null || true) + if [[ -z "$parent_remaining" && "$parent" != "$HOME" ]]; then + rmdir "$parent" 2>/dev/null || true + fi + fi + fi + done +} + +main From edc43a6923c2ec7779f34b897bf92c7cf979f40c Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 14 Aug 2026 18:34:41 +0200 Subject: [PATCH 2/7] =?UTF-8?q?docs:=20ADR=200008=20=E2=80=94=20armonia=20?= =?UTF-8?q?subsumes=20~/.amico=20state=20+=20CONTEXT.md=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the decision that ~/armonia/ becomes the single state root for all amicode product state. ~/.amico/ becomes a backward-compatible symlink farm (phase B), retired by user-run script once ArmoniaService resolves paths directly (phase C, gated on #326). Updates the Armonia definition in CONTEXT.md to reflect full state ownership and name ~/.amico as a transitional symlink farm. --- CONTEXT.md | 4 +- docs/adr/0008-armonia-subsumes-amico-state.md | 67 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0008-armonia-subsumes-amico-state.md diff --git a/CONTEXT.md b/CONTEXT.md index 1dcfce4f..1ed1283a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -43,8 +43,8 @@ _Avoid_: chat, side chat, ticket ### Knowledge **Armonia**: -The canonical workspace and knowledge system. As a workspace: the `~/armonia/` directory tree (`repos/{packages,demos}`, `data/{env,problems,runs,vaults}`) surfaced as the structured "Armonia" sidebar panel via ArmoniaService. As a knowledge system: the precedence-ordered stack of mounted Vaults under `data/vaults/` (personal → project → team) that the agent reads for context. The sidebar panel shows both — semantic buckets for the full workspace, with Vaults as one bucket. -_Avoid_: Vault (as the system name), knowledge base +The canonical workspace and single state root. As a workspace: the `~/armonia/` directory tree (`repos/{packages,demos}`, `data/{config,env/julia,problems,runs,vaults,library,fleet,ledger,devices,authoring,amicode}`) surfaced as the structured "Armonia" sidebar panel via ArmoniaService. As a knowledge system: the precedence-ordered stack of mounted Vaults under `data/vaults/` (personal → project → team) that the agent reads for context. All amicode product state lives here; `~/.amico/` is a backward-compatible symlink farm into `data/` (ADR 0008), retired once ArmoniaService resolves paths directly. +_Avoid_: Vault (as the system name), knowledge base, ~/.amico (as a canonical location) **Vault**: One mounted knowledge tier within Armonia — a git-backed store of notes, specs, and catalog entries at a single precedence level (personal / project / team). Many Vaults mount into the Armonia stack; the panel lists them as its roots and reads them top-to-bottom. diff --git a/docs/adr/0008-armonia-subsumes-amico-state.md b/docs/adr/0008-armonia-subsumes-amico-state.md new file mode 100644 index 00000000..9d47b32b --- /dev/null +++ b/docs/adr/0008-armonia-subsumes-amico-state.md @@ -0,0 +1,67 @@ +# Armonia subsumes ~/.amico — symlink farm now, ArmoniaService retirement later + +Status: accepted (2026-08-14) + +All amicode product state migrates from `~/.amico/` into `~/armonia/data/`. The +`~/.amico/` directory becomes a backward-compatible symlink farm (phase B) that is +retired by a user-run cleanup script once ArmoniaService resolves paths directly +(phase C). Opencode's XDG paths (`~/.config/opencode/`, `~/.local/share/opencode/`) +are untouched — they belong to the engine, not the product. + +**Why:** `~/.amico/` accumulated ~15 distinct paths organically. Issue #326 (Armonia +as default workspace) needs a single tree it can browse, watch, and resolve against. +Leaving state scattered across two roots (`~/armonia/` for repos+artifacts, +`~/.amico/` for everything else) means the sidebar can never show the full picture +and the session cwd story has a permanent asterisk. Subsuming everything under +armonia gives one tree, one backup target, one mental model. + +**Why a symlink farm (not a code refactor first):** the codebase has ~30 call sites +that resolve `homedir() + ".amico" + X`. Rewriting them all requires ArmoniaService +(#326) which is a substantial PR. The symlink farm makes the filesystem migration +zero-breakage today — every existing path resolves transparently — while the code +catches up at its own pace. + +**Why config files stay as real files at `~/.amico/` (not symlinked):** file-level +symlinks break if the target is deleted and recreated (the symlink becomes dangling +and a new real file appears at the original path). Credential files like `cloud.json` +are written atomically (delete + rename) by multiple code paths. Directory symlinks +do not have this problem — `readdir` follows them transparently. + +**Considered:** + +- **(A) `~/.amico/` stays canonical, armonia is browse-only** — rejected: perpetuates + two roots, the sidebar is a projection of reality rather than reality itself, and + "where does X live?" remains a question with two answers. +- **(B) Single symlink `~/.amico → ~/armonia/data`** — rejected: forces a flat layout + inside `data/` that matches `~/.amico/`'s structure exactly, blocking any + reorganization (e.g. `data/config/`, `data/env/julia/`). +- **(C) Immediate code refactor (no symlink phase)** — rejected: blocks the migration + on #326 and a ~30-site refactor; users cannot benefit until both land. + +**Chosen: (D) symlink farm now, retirement script gated on ArmoniaService.** The +retirement script (`tools/retire-amico-symlinks.sh`) ships in the same PR and checks +for a marker file (`~/armonia/.armonia-active`) written by ArmoniaService on boot +before it will run. This ensures the user cannot accidentally retire the symlinks +while the code still reads through them. + +**Layout after migration:** + +``` +~/armonia/data/ + config/ profile.json, cloud.json, pasqal.json, connections.json, lab.toml, mounts.toml + env/julia/ the provisioned Julia project + problems/ problem workspaces + runs/ run output + vaults/ mounted vaults + library/ uploaded papers + fleet/ fleet registry + tunnel state + ledger/ runs.jsonl, claims.jsonl, approvals/ + devices/ calibration state + authoring/ authoring.json + amicode/ entitlements, solver mode +``` + +**Exit condition (phase C):** all `homedir() + ".amico" + X` callers migrated to +`ArmoniaService.resolve()`, ArmoniaService writes `~/armonia/.armonia-active` on +boot, the retirement script passes its gate check, and the user runs it. Phase C is +a separate issue gated on #326 with the `hitl` label. From 9d4364b8b527267b3d76f609b1c7f7b11a38f3c5 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 15 Aug 2026 12:00:28 +0200 Subject: [PATCH 3/7] feat: expand migration scripts + add retirement script + skillRoots switch (#386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bootstrap (bootstrap-armonia.sh): - Full skeleton: data/{config,env/julia,problems,runs,vaults,library,fleet, ledger,devices,authoring,amicode} - 10 symlinks from ~/.amico/ (9 standard + ops/fleet special case) Migration (migrate-to-armonia.sh): - Moves all ~/.amico/ directories (9 + ops/fleet) to armonia, replaces with symlinks - Copies config files to data/config/ without removing originals - Scans VS Code global settings.json for stale paths, prompts before rewriting - Final diagnostic warns about unknown entries under ~/.amico/ Retirement (retire-amico-symlinks.sh — new): - Gated on ~/armonia/.armonia-active marker (written by ArmoniaService #326) - Removes all symlinks, moves config files to data/config/ (authoritative), removes ~/.amico/ if empty Code: - DEFAULT_SKILL_ROOTS hard-switched from ~/harmoniqs/packages to ~/armonia/repos/packages/ (forcing function for migration) All three scripts are idempotent (verified by re-run tests). --- packages/extension/src/opencode_config.ts | 2 +- tools/bootstrap-armonia.sh | 67 +++++- tools/migrate-to-armonia.sh | 236 +++++++++++++++++++++- tools/retire-amico-symlinks.sh | 164 +++++++++++++++ 4 files changed, 453 insertions(+), 16 deletions(-) create mode 100755 tools/retire-amico-symlinks.sh diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 5eace34c..0641d582 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -146,7 +146,7 @@ export const DEFAULT_SCORES_ROOT = path.resolve(__dirname, "..", "scores"); * at packages/extension/skills/ (moved out of the retired amico-plugin repo — * they are product content, versioned with the extension). Overridable * via settings (Task 6). */ -export const DEFAULT_SKILL_ROOTS = [path.join(os.homedir(), "harmoniqs", "packages")]; +export const DEFAULT_SKILL_ROOTS = [path.join(os.homedir(), "armonia", "repos", "packages")]; /** Library roots scanned (first-root-wins), TYPED by admitted surface set * (ADR-0003, amicode#242; roots re-homed after the amico-plugin dissolution): * 1. the in-repo public library (packages/extension/skills/, sibling of diff --git a/tools/bootstrap-armonia.sh b/tools/bootstrap-armonia.sh index b9b75ea2..417caf6f 100755 --- a/tools/bootstrap-armonia.sh +++ b/tools/bootstrap-armonia.sh @@ -8,7 +8,7 @@ set -euo pipefail # ~/armonia/repos/packages/ Julia libraries (Piccolo.jl, …) # ~/armonia/repos/demos/ demo galleries (atoms-demo, …) # ~/armonia/repos/ apps, forks, projects (amicode, …) -# ~/armonia/data/{env,problems,runs,vaults} +# ~/armonia/data/{config,env/julia,problems,runs,vaults,library,fleet,ledger,devices,authoring,amicode} # # Usage: # bootstrap-armonia.sh [--minimal|--standard|--full] @@ -31,7 +31,7 @@ for arg in "$@"; do case "$arg" in --minimal|--standard|--full) TIER="${arg#--}" ;; -h|--help) - sed -n '2,22p' "$0"; exit 0 ;; + sed -n '2,24p' "$0"; exit 0 ;; *) echo "unknown arg: $arg" >&2; exit 64 ;; esac done @@ -74,7 +74,7 @@ main() { echo echo "==> Done." - echo " Layout: ~/armonia/{repos/{packages,demos,...}, data/{env,problems,runs,vaults}}" + echo " Layout: ~/armonia/{repos/{packages,demos,...}, data/{config,env/julia,problems,...}}" echo " Open it: open ~/armonia/ (or add ~/armonia to your VS Code workspace)" } @@ -82,9 +82,19 @@ main() { make_layout() { echo "--- layout ---" mkdir -p "${ARMONIA}/repos/packages" "${ARMONIA}/repos/demos" \ - "${ARMONIA}/data/env" "${ARMONIA}/data/problems" \ - "${ARMONIA}/data/runs" "${ARMONIA}/data/vaults" - echo " ~/armonia/{repos/{packages,demos}, data/{env,problems,runs,vaults}} ready" + "${ARMONIA}/data/config" \ + "${ARMONIA}/data/env/julia" \ + "${ARMONIA}/data/problems" \ + "${ARMONIA}/data/runs" \ + "${ARMONIA}/data/vaults" \ + "${ARMONIA}/data/library" \ + "${ARMONIA}/data/fleet" \ + "${ARMONIA}/data/ledger" \ + "${ARMONIA}/data/devices" \ + "${ARMONIA}/data/authoring" \ + "${ARMONIA}/data/amicode" + echo " ~/armonia/data/{config,env/julia,problems,runs,vaults,library,fleet,ledger,devices,authoring,amicode} ready" + echo " ~/armonia/repos/{packages,demos} ready" } # ------------------------------------------------------------------- @@ -92,13 +102,38 @@ make_layout() { # If ~/.amico/ already exists as a REAL directory with content, that is # the migration case — point the user at migrate-to-armonia.sh instead of # clobbering it. +# +# The full symlink farm (10 links): +# julia → data/env/julia +# problems → data/problems +# runs → data/runs +# vaults → data/vaults +# library → data/library +# ops/fleet → data/fleet (ops/ is a real dir; fleet is the symlink inside) +# ledger → data/ledger +# devices → data/devices +# authoring → data/authoring +# amicode → data/amicode wire_amico_links() { echo "--- ~/.amico links ---" - local pairs=("julia:env" "problems:problems" "runs:runs" "vaults:vaults") + mkdir -p "$AMICO" + + # Standard directory symlinks: "amico_name:armonia_target" + local pairs=( + "julia:env/julia" + "problems:problems" + "runs:runs" + "vaults:vaults" + "library:library" + "ledger:ledger" + "devices:devices" + "authoring:authoring" + "amicode:amicode" + ) + for pair in "${pairs[@]}"; do local name="${pair%%:*}" target="${pair##*:}" local src="${AMICO}/${name}" dest="${ARMONIA}/data/${target}" - mkdir -p "$AMICO" if [[ -L "$src" ]]; then echo " (symlink) ~/.amico/${name}" elif [[ -d "$src" && -n "$(ls -A "$src" 2>/dev/null)" ]]; then @@ -111,6 +146,22 @@ wire_amico_links() { echo " linked ~/.amico/${name} → data/${target}" fi done + + # Special case: ops/fleet (ops/ is a real dir, fleet is a symlink inside it) + mkdir -p "${AMICO}/ops" + local fleet_src="${AMICO}/ops/fleet" + local fleet_dest="${ARMONIA}/data/fleet" + if [[ -L "$fleet_src" ]]; then + echo " (symlink) ~/.amico/ops/fleet" + elif [[ -d "$fleet_src" && -n "$(ls -A "$fleet_src" 2>/dev/null)" ]]; then + echo " (real dir, not empty) ~/.amico/ops/fleet — run tools/migrate-to-armonia.sh first" + elif [[ -d "$fleet_src" ]]; then + rmdir "$fleet_src" && ln -s "$fleet_dest" "$fleet_src" + echo " linked ~/.amico/ops/fleet → data/fleet" + else + ln -s "$fleet_dest" "$fleet_src" + echo " linked ~/.amico/ops/fleet → data/fleet" + fi } # ------------------------------------------------------------------- diff --git a/tools/migrate-to-armonia.sh b/tools/migrate-to-armonia.sh index 029c5636..b0ec0784 100755 --- a/tools/migrate-to-armonia.sh +++ b/tools/migrate-to-armonia.sh @@ -10,7 +10,13 @@ set -euo pipefail # ~/armonia/repos/packages/ Julia libraries (Piccolo.jl, …) # ~/armonia/repos/demos/ demo galleries (atoms-demo, …) # ~/armonia/repos/ apps, forks, research projects (amicode, passaggio, …) -# ~/armonia/data/{env,problems,runs,vaults} +# ~/armonia/data/{config,env/julia,problems,runs,vaults,library,fleet,ledger,devices,authoring,amicode} +# +# Also: +# - Copies config files to data/config/ (profile.json, cloud.json, pasqal.json, +# connections.json, lab.toml, mounts.toml) WITHOUT removing originals from ~/.amico/ +# - Scans VS Code global settings.json for stale paths, prompts before rewriting +# - Final diagnostic warns about non-symlink entries under ~/.amico/ not in the known set ARMONIA="${HOME}/armonia" AMICO="${HOME}/.amico" @@ -27,10 +33,34 @@ REPO_SOURCES=( # ---- discover data dirs to migrate ---- # Each entry: "amico_dir armonia_target" DATA_DIRS=( - "julia env" + "julia env/julia" "problems problems" "runs runs" "vaults vaults" + "library library" + "ledger ledger" + "devices devices" + "authoring authoring" + "amicode amicode" +) + +# Special: ops/fleet → data/fleet (ops/ is a real dir, fleet is the symlink inside) +# Handled separately in migrate_data_special. + +# Config files that stay as real files at ~/.amico/ but are COPIED to data/config/. +CONFIG_FILES=( + profile.json + cloud.json + pasqal.json + connections.json + lab.toml + mounts.toml +) + +# Known entries under ~/.amico/ that are expected after migration (symlinks + config + ops/). +KNOWN_ENTRIES=( + julia problems runs vaults library ledger devices authoring amicode ops + profile.json cloud.json pasqal.json connections.json lab.toml mounts.toml ) # Known demo repo names → routed to repos/demos/. A source dir literally named @@ -46,12 +76,25 @@ main() { echo mkdir -p "${ARMONIA}/repos/packages" "${ARMONIA}/repos/demos" \ - "${ARMONIA}/data/env" "${ARMONIA}/data/problems" \ - "${ARMONIA}/data/runs" "${ARMONIA}/data/vaults" + "${ARMONIA}/data/config" \ + "${ARMONIA}/data/env/julia" \ + "${ARMONIA}/data/problems" \ + "${ARMONIA}/data/runs" \ + "${ARMONIA}/data/vaults" \ + "${ARMONIA}/data/library" \ + "${ARMONIA}/data/fleet" \ + "${ARMONIA}/data/ledger" \ + "${ARMONIA}/data/devices" \ + "${ARMONIA}/data/authoring" \ + "${ARMONIA}/data/amicode" converge_buckets migrate_repos migrate_data + migrate_data_special + copy_config_files + scan_vscode_settings + diagnostic_pass cleanup_empty_parents echo @@ -196,15 +239,194 @@ migrate_data() { continue fi - # source is a real dir, dest does not exist → move + symlink + # source is a real dir, dest does not exist or is empty → move + symlink if [[ -d "$src" && ! -L "$src" ]]; then echo " mv ~/.amico/${amico_name} → data/${armonia_name}" - mv "$src" "$dest" + # Move contents rather than dir (dest already exists from mkdir -p) + if [[ -n "$(ls -A "$src" 2>/dev/null)" ]]; then + cp -a "$src"/. "$dest"/ + fi + rm -rf "$src" ln -s "$dest" "$src" else - echo " (skip) ~/.amico/${amico_name} does not exist" + # source doesn't exist → create the symlink anyway + echo " linked ~/.amico/${amico_name} → data/${armonia_name}" + ln -s "$dest" "$src" + fi + done +} + +# ------------------------------------------------------------------- +# Special case: ~/.amico/ops/fleet → ~/armonia/data/fleet +# ops/ is a real directory; fleet is a symlink inside it. +migrate_data_special() { + echo "--- data (special: ops/fleet) ---" + mkdir -p "${AMICO}/ops" + local fleet_src="${AMICO}/ops/fleet" + local fleet_dest="${ARMONIA}/data/fleet" + + if [[ -L "$fleet_src" ]]; then + echo " (symlink) ~/.amico/ops/fleet" + elif [[ -d "$fleet_src" && -n "$(ls -A "$fleet_src" 2>/dev/null)" ]]; then + echo " mv ~/.amico/ops/fleet → data/fleet" + cp -a "$fleet_src"/. "$fleet_dest"/ + rm -rf "$fleet_src" + ln -s "$fleet_dest" "$fleet_src" + elif [[ -d "$fleet_src" ]]; then + rmdir "$fleet_src" + ln -s "$fleet_dest" "$fleet_src" + echo " linked ~/.amico/ops/fleet → data/fleet" + else + ln -s "$fleet_dest" "$fleet_src" + echo " linked ~/.amico/ops/fleet → data/fleet" + fi +} + +# ------------------------------------------------------------------- +# Copy config files to data/config/ WITHOUT removing originals. +# Config files stay as real files at ~/.amico/ (atomic write pattern). +copy_config_files() { + echo "--- config files → data/config/ ---" + for f in "${CONFIG_FILES[@]}"; do + local src="${AMICO}/${f}" + local dest="${ARMONIA}/data/config/${f}" + if [[ -f "$src" ]]; then + cp -p "$src" "$dest" + echo " copied ~/.amico/${f} → data/config/${f}" + else + echo " (skip) ~/.amico/${f} does not exist" + fi + done +} + +# ------------------------------------------------------------------- +# Scan VS Code global settings.json for paths pointing at moved directories. +# Print the stale→new mapping and prompt for confirmation before rewriting. +scan_vscode_settings() { + echo "--- VS Code settings scan ---" + + # Platform-dependent settings location + local settings_file + case "$(uname)" in + Darwin) settings_file="${HOME}/Library/Application Support/Code/User/settings.json" ;; + Linux) settings_file="${HOME}/.config/Code/User/settings.json" ;; + *) echo " (skip) unsupported platform for settings scan"; return 0 ;; + esac + + if [[ ! -f "$settings_file" ]]; then + echo " (skip) settings.json not found at: $settings_file" + return 0 + fi + + # Source paths that have been moved into armonia. We scan for any amicode.* + # setting whose value contains these prefixes. + local -a stale_paths=() + local -a new_paths=() + local found_stale=0 + + # Check for stale harmoniqs source paths that are now under armonia/repos + for src_dir in "${REPO_SOURCES[@]}"; do + if grep -q "$src_dir" "$settings_file" 2>/dev/null; then + found_stale=1 + break fi done + + if [[ $found_stale -eq 0 ]]; then + echo " (ok) no stale paths found in settings.json" + return 0 + fi + + echo "" + echo " Found paths in VS Code settings that may be stale after migration:" + echo "" + + # Build the mapping and show it + local -a sed_args=() + for src_dir in "${REPO_SOURCES[@]}"; do + if grep -q "$src_dir" "$settings_file" 2>/dev/null; then + # Map the old source dir to armonia/repos (the move destination) + local escaped_src escaped_dest + escaped_src=$(printf '%s\n' "$src_dir" | sed 's/[&/\]/\\&/g') + escaped_dest=$(printf '%s\n' "${ARMONIA}/repos" | sed 's/[&/\]/\\&/g') + sed_args+=(-e "s|${src_dir}|${ARMONIA}/repos|g") + # Show specific matches + grep -n "$src_dir" "$settings_file" | while IFS= read -r line; do + echo " $line" + done + echo " → would replace: $src_dir → ${ARMONIA}/repos" + echo "" + fi + done + + if [[ ${#sed_args[@]} -eq 0 ]]; then + echo " (ok) no actionable stale paths" + return 0 + fi + + # Prompt for confirmation + echo -n " Rewrite these paths in settings.json? [y/N] " + if [[ -t 0 ]]; then + read -r answer + else + answer="n" + echo "(non-interactive, skipping)" + fi + + if [[ "$answer" =~ ^[Yy] ]]; then + # Backup then rewrite + cp -p "$settings_file" "${settings_file}.bak.$(date +%s)" + sed -i '' "${sed_args[@]}" "$settings_file" + echo " done (backup saved as settings.json.bak.*)" + else + echo " skipped — you can manually update these paths later" + fi +} + +# ------------------------------------------------------------------- +# Final diagnostic: warn about any non-symlink entries under ~/.amico/ that +# are not in the known list. Informational only — never moves unknown entries. +diagnostic_pass() { + echo "--- diagnostic ---" + local unknown_found=0 + + if [[ ! -d "$AMICO" ]]; then + echo " (ok) ~/.amico/ does not exist" + return 0 + fi + + while IFS= read -r entry; do + [[ -z "$entry" ]] && continue + local name + name=$(basename "$entry") + + # Check if this is a known entry + local known=0 + for k in "${KNOWN_ENTRIES[@]}"; do + if [[ "$name" == "$k" ]]; then + known=1 + break + fi + done + + if [[ $known -eq 0 ]]; then + if [[ $unknown_found -eq 0 ]]; then + echo " WARNING: unknown entries found under ~/.amico/ (not migrated):" + unknown_found=1 + fi + if [[ -L "$entry" ]]; then + echo " (symlink) $name → $(readlink "$entry")" + elif [[ -d "$entry" ]]; then + echo " (dir) $name" + else + echo " (file) $name" + fi + fi + done < <(find "$AMICO" -mindepth 1 -maxdepth 1 2>/dev/null) + + if [[ $unknown_found -eq 0 ]]; then + echo " (ok) all entries under ~/.amico/ are known" + fi } # ------------------------------------------------------------------- diff --git a/tools/retire-amico-symlinks.sh b/tools/retire-amico-symlinks.sh new file mode 100755 index 00000000..ac8cf7a1 --- /dev/null +++ b/tools/retire-amico-symlinks.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +set -euo pipefail + +# retire-amico-symlinks.sh +# Phase C cleanup: remove the ~/.amico/ symlink farm once ArmoniaService is live. +# +# GATED on ~/armonia/.armonia-active — refuses to run if absent. This marker is +# written by ArmoniaService on boot (#326), proving it resolves paths directly +# from ~/armonia/ and no longer needs the ~/.amico/ indirection. +# +# What it does (when gate passes): +# 1. Removes all directory symlinks under ~/.amico/ +# 2. Moves config files from ~/.amico/ to ~/armonia/data/config/ (making +# data/config/ authoritative — the caller now reads from there) +# 3. Removes ~/.amico/ if empty +# +# Idempotent: safe to re-run. Symlinks already removed are skipped; config +# files already at data/config/ are not overwritten. +# +# Usage: +# tools/retire-amico-symlinks.sh + +ARMONIA="${HOME}/armonia" +AMICO="${HOME}/.amico" +MARKER="${ARMONIA}/.armonia-active" + +# Config files to move (the authoritative copies land at data/config/). +CONFIG_FILES=( + profile.json + cloud.json + pasqal.json + connections.json + lab.toml + mounts.toml +) + +# All symlink entries we expect to find and remove. +SYMLINK_ENTRIES=( + julia + problems + runs + vaults + library + ledger + devices + authoring + amicode +) + +# =================================================================== +main() { + echo "==> retire-amico-symlinks" + echo + + # ---- gate: marker must exist ---- + if [[ ! -f "$MARKER" ]]; then + echo "ERROR: ~/armonia/.armonia-active not found." + echo "" + echo "This script can only run after ArmoniaService is live and resolving" + echo "paths directly from ~/armonia/. The marker file is written by" + echo "ArmoniaService on boot (see #326)." + echo "" + echo "If you are sure ArmoniaService is active, check that it wrote:" + echo " $MARKER" + exit 1 + fi + + echo " marker found: $MARKER" + echo + + remove_symlinks + remove_ops_fleet_symlink + move_config_files + remove_amico_dir + + echo + echo "==> Done. ~/.amico/ symlink farm retired." + echo " ArmoniaService now owns all paths directly under ~/armonia/." +} + +# ------------------------------------------------------------------- +remove_symlinks() { + echo "--- remove symlinks ---" + for name in "${SYMLINK_ENTRIES[@]}"; do + local src="${AMICO}/${name}" + if [[ -L "$src" ]]; then + rm "$src" + echo " removed ~/.amico/${name}" + elif [[ -e "$src" ]]; then + echo " WARNING: ~/.amico/${name} is NOT a symlink — left in place" + else + echo " (already gone) ~/.amico/${name}" + fi + done +} + +# ------------------------------------------------------------------- +remove_ops_fleet_symlink() { + echo "--- remove ops/fleet symlink ---" + local fleet="${AMICO}/ops/fleet" + if [[ -L "$fleet" ]]; then + rm "$fleet" + echo " removed ~/.amico/ops/fleet" + elif [[ -e "$fleet" ]]; then + echo " WARNING: ~/.amico/ops/fleet is NOT a symlink — left in place" + else + echo " (already gone) ~/.amico/ops/fleet" + fi + + # Remove ops/ if empty + if [[ -d "${AMICO}/ops" ]]; then + if [[ -z "$(ls -A "${AMICO}/ops" 2>/dev/null)" ]]; then + rmdir "${AMICO}/ops" + echo " removed empty ~/.amico/ops/" + else + echo " WARNING: ~/.amico/ops/ not empty — left in place" + fi + fi +} + +# ------------------------------------------------------------------- +# Move config files from ~/.amico/ to data/config/ (making data/config/ +# authoritative). Does not overwrite if dest already newer. +move_config_files() { + echo "--- move config files → data/config/ (authoritative) ---" + mkdir -p "${ARMONIA}/data/config" + + for f in "${CONFIG_FILES[@]}"; do + local src="${AMICO}/${f}" + local dest="${ARMONIA}/data/config/${f}" + if [[ -f "$src" ]]; then + # Move (not copy) — data/config/ becomes the only copy + mv "$src" "$dest" + echo " moved ~/.amico/${f} → data/config/${f}" + elif [[ -f "$dest" ]]; then + echo " (already at dest) data/config/${f}" + else + echo " (skip) ${f} not found anywhere" + fi + done +} + +# ------------------------------------------------------------------- +# Remove ~/.amico/ if empty. +remove_amico_dir() { + echo "--- cleanup ---" + if [[ ! -d "$AMICO" ]]; then + echo " (already gone) ~/.amico/" + return 0 + fi + + if [[ -z "$(ls -A "$AMICO" 2>/dev/null)" ]]; then + rmdir "$AMICO" + echo " removed empty ~/.amico/" + else + echo " WARNING: ~/.amico/ not empty after cleanup — remaining entries:" + ls -la "$AMICO" | tail -n +4 | while IFS= read -r line; do + echo " $line" + done + echo " (left in place — inspect manually)" + fi +} + +main From 1abf0b402e415bd9b2441003ddfcd881d7f5c337 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 15 Aug 2026 12:05:50 +0200 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20add=20migrate-to-armonia=20skill=20?= =?UTF-8?q?=E2=80=94=20guided=20walkthrough=20for=20armonia=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user-invocable skill that walks through the full migration process: assess current state, bootstrap (fresh machine), migrate (existing user), verify symlinks, and retire (phase C, gated). Orchestrates the three bash scripts with human checkpoints between each phase. --- .../skills/migrate-to-armonia/SKILL.md | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 packages/extension/skills/migrate-to-armonia/SKILL.md diff --git a/packages/extension/skills/migrate-to-armonia/SKILL.md b/packages/extension/skills/migrate-to-armonia/SKILL.md new file mode 100644 index 00000000..3e62a2ff --- /dev/null +++ b/packages/extension/skills/migrate-to-armonia/SKILL.md @@ -0,0 +1,190 @@ +--- +name: migrate-to-armonia +description: Walk a user through the full armonia migration — assess current state, run bootstrap or migrate, verify symlinks, and optionally rewrite VS Code settings. Use when the user wants to migrate to armonia, set up their workspace, or fix a broken migration. +agents: [researcher, experimenter, engineer] +surface: public +scenarios: [fresh-machine, existing-user-migrate, re-run-idempotent, partial-fix] +--- + +Guide the user through migrating their Amicode state from `~/.amico/` into the canonical `~/armonia/` layout. This is a one-time operation (idempotent, safe to re-run). + +## Usage + +`/migrate-to-armonia` — full guided walkthrough. + +`/migrate-to-armonia check` — assess current state without changing anything. + +`/migrate-to-armonia fix` — re-run on a partially migrated system. + +The argument is: $ARGUMENTS + +## Instructions + +This skill orchestrates the three migration scripts (`tools/bootstrap-armonia.sh`, +`tools/migrate-to-armonia.sh`, `tools/retire-amico-symlinks.sh`) with human checkpoints +between each phase. Never run all three unattended — the user confirms each step. + +### Phase overview + +| Phase | Script | What it does | When to use | +|-------|--------|-------------|-------------| +| **A. Bootstrap** | `tools/bootstrap-armonia.sh --minimal` | Creates `~/armonia/` skeleton + symlink farm from `~/.amico/` | Fresh machine (no `~/armonia/` yet) | +| **B. Migrate** | `tools/migrate-to-armonia.sh` | Moves real dirs from `~/.amico/` into armonia, replaces with symlinks, copies config, scans VS Code settings | Existing user with state under `~/.amico/` | +| **C. Retire** | `tools/retire-amico-symlinks.sh` | Removes symlink farm, makes `data/config/` authoritative | Only after ArmoniaService is live (gated on marker) | + +Phase C is **blocked** until `~/armonia/.armonia-active` exists (written by ArmoniaService). +The script refuses to run without it — never bypass this gate manually. + +--- + +## Procedure + +### Step 0: Assess current state + +Before doing anything, run diagnostics: + +```bash +echo "--- armonia ---" +[[ -d ~/armonia ]] && echo "exists" || echo "NOT FOUND" +[[ -d ~/armonia/data ]] && echo " data/ exists" || echo " data/ NOT FOUND" + +echo "--- ~/.amico ---" +[[ -d ~/.amico ]] && echo "exists" || echo "NOT FOUND" +ls -la ~/.amico/ 2>/dev/null | head -20 + +echo "--- marker ---" +[[ -f ~/armonia/.armonia-active ]] && echo "ArmoniaService active" || echo "ArmoniaService NOT active (phase C blocked)" +``` + +Report findings to the user. Then route: + +- **No `~/armonia/`** → start at Step 1 (bootstrap) +- **`~/armonia/` exists but `~/.amico/` has real dirs (not symlinks)** → start at Step 2 (migrate) +- **Both exist, `~/.amico/` is all symlinks** → already migrated; offer Step 3 check or Step 4 (retire, if marker present) +- **User said "check" or "fix"** → report state and offer the appropriate next step + +### Step 1: Bootstrap (fresh machine) + +Confirm with the user, then run: + +```bash +bash ~/armonia/repos/amicode/tools/bootstrap-armonia.sh --minimal +``` + +Or if the repo isn't cloned yet (the script is curl-able): + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/harmoniqs/amicode/main/tools/bootstrap-armonia.sh) --minimal +``` + +After completion, verify: +- `~/armonia/data/` has all expected subdirs (config, env/julia, problems, runs, vaults, library, fleet, ledger, devices, authoring, amicode) +- `~/.amico/` exists with symlinks pointing into armonia + +Report the result. If the user also has existing state to migrate (repos under `~/harmoniqs/` etc.), proceed to Step 2. + +### Step 2: Migrate (existing user) + +**Pre-flight:** show what will happen — "This moves your real directories from `~/.amico/` into `~/armonia/data/` and replaces them with symlinks. Your config files (profile.json, cloud.json, etc.) stay in place but get copied to `data/config/` for visibility." + +Confirm with the user, then run: + +```bash +bash ~/armonia/repos/amicode/tools/migrate-to-armonia.sh +``` + +The script will: +1. Move repos from `~/harmoniqs/`, `~/_dev/harmoniqs/`, etc. into `~/armonia/repos/` +2. Move data dirs from `~/.amico/` into `~/armonia/data/`, replace with symlinks +3. Handle `ops/fleet` specially (real `ops/` dir, `fleet` symlink inside) +4. Copy config files to `data/config/` +5. **Scan VS Code settings** — it will show stale paths and prompt. Let the user decide. +6. Run a diagnostic pass — warn about unknown entries + +After completion, verify: + +```bash +# All should be symlinks now +file ~/.amico/julia ~/.amico/problems ~/.amico/runs ~/.amico/vaults ~/.amico/library ~/.amico/ledger ~/.amico/devices ~/.amico/authoring ~/.amico/amicode ~/.amico/ops/fleet +``` + +Report any warnings from the diagnostic pass. If unknown entries were flagged, explain what they are (the script never moves them — the user decides). + +### Step 3: Verify + +Run this to confirm everything resolves correctly: + +```bash +echo "--- symlink verification ---" +for d in julia problems runs vaults library ledger devices authoring amicode; do + if [[ -L ~/.amico/$d ]]; then + target=$(readlink ~/.amico/$d) + [[ -d "$target" ]] && echo "OK ~/.amico/$d → $target" || echo "BROKEN ~/.amico/$d → $target (target missing!)" + elif [[ -d ~/.amico/$d ]]; then + echo "REAL DIR ~/.amico/$d (not migrated)" + else + echo "MISSING ~/.amico/$d" + fi +done + +# ops/fleet +if [[ -L ~/.amico/ops/fleet ]]; then + target=$(readlink ~/.amico/ops/fleet) + [[ -d "$target" ]] && echo "OK ~/.amico/ops/fleet → $target" || echo "BROKEN ~/.amico/ops/fleet → $target" +else + echo "MISSING ~/.amico/ops/fleet" +fi + +echo "" +echo "--- config files ---" +for f in profile.json cloud.json pasqal.json connections.json lab.toml mounts.toml; do + [[ -f ~/.amico/$f ]] && echo "OK ~/.amico/$f (real file)" || echo "(absent) ~/.amico/$f" + [[ -f ~/armonia/data/config/$f ]] && echo "OK data/config/$f (copy)" || echo "(absent) data/config/$f" +done +``` + +Report results. Any BROKEN or REAL DIR entries need fixing (re-run migrate). + +### Step 4: Retire (phase C — gated) + +Only offer this step if `~/armonia/.armonia-active` exists. If it does not exist, explain: + +> "The retirement script can only run once ArmoniaService is live (it writes a marker file at `~/armonia/.armonia-active` on boot). This is phase C — you don't need to do anything until that ships." + +If the marker is present: + +```bash +bash ~/armonia/repos/amicode/tools/retire-amico-symlinks.sh +``` + +This removes all symlinks, moves config files from `~/.amico/` to `data/config/` (making it authoritative), and removes `~/.amico/` if empty. + +### Step 5: VS Code settings (optional, manual) + +If the migration script's VS Code scan was skipped (non-interactive) or the user wants to revisit: + +The settings to check in `~/Library/Application Support/Code/User/settings.json` (macOS) or `~/.config/Code/User/settings.json` (Linux): + +- `amicode.opencodeBinary` — should point to the binary under `~/armonia/repos/amicode/` if it moved +- `amicode.devAssetRoot` — same +- `amicode.skillRoots` — the code default is now `~/armonia/repos/packages/`; if explicitly set, update it + +Show the user the current values and suggest corrections. Never rewrite without asking. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| "command not found: amico-run" after migration | Binary path moved | Update `amicode.opencodeBinary` in VS Code settings | +| Skills not loading | `skillRoots` points at old `~/harmoniqs/packages` | Remove the explicit setting (code default now correct) or update to `~/armonia/repos/packages/` | +| Broken symlink under `~/.amico/` | Target dir missing in armonia | Re-run bootstrap (`--minimal`) to recreate missing dirs | +| "real dir, not empty" warning on bootstrap | Existing state not yet migrated | Run the migrate script instead | +| Unknown entries warning | Files/dirs that predate the migration scheme | Inspect manually; safe to leave or move by hand | + +## Related + +- **ADR 0008** (`docs/adr/0008-armonia-subsumes-amico-state.md`) — the architectural decision +- **#326** — ArmoniaService (writes the `.armonia-active` marker, enabling phase C) +- **#386** — the implementation issue for the scripts themselves From 06c80335c34dfa6de60ce6c5b24cf5cfec9a2be7 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 15 Aug 2026 12:13:36 +0200 Subject: [PATCH 5/7] fix: rebuild copies skills/ to installed extension The dev-tools 'Rebuild Locally' copies dist/*.js files into the installed extension path but did not copy skills/. Since DEFAULT_LIBRARY_ROOTS resolves via __dirname (which points at the installed extension's dist/), new or modified skills in the dev repo were invisible until a fresh vsix install. Now the rebuild syncs the full skills/ directory alongside dist/, so local skill changes (like migrate-to-armonia) are immediately available after a rebuild + reload. --- packages/extension/src/chat_bridge.ts | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index d30f6edd..7cde437a 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -547,6 +547,44 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean } catch (copyErr) { console.warn("[amicode/bridge] extension dist copy failed:", copyErr); } + // Sync content directories that resolve via __dirname or + // ctx.extensionPath at runtime. Without this, local changes to + // skills, scores, templates, exemplars, the plugin, julia pins, + // AGENTS.md, and tools are invisible until a fresh vsix install. + const contentDirs = [ + "skills", + "scores", + "templates", + "exemplars", + "opencode-plugin", + "julia", + "tools", + ]; + for (const dir of contentDirs) { + try { + const src = path.join(amicodePath, "packages", "extension", dir); + const dest = path.join(installedExt.extensionPath, dir); + if (fs.existsSync(src)) { + fs.cpSync(src, dest, { recursive: true }); + } + } catch (syncErr) { + console.warn(`[amicode/bridge] ${dir}/ sync failed:`, syncErr); + } + } + // Sync top-level markdown files (AGENTS.md, DISTILLER.md, etc.) + const mdFiles = ["AGENTS.md", "DISTILLER.md", "CONTRACT.md"]; + for (const f of mdFiles) { + try { + const src = path.join(amicodePath, "packages", "extension", f); + const dest = path.join(installedExt.extensionPath, f); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest); + } + } catch (syncErr) { + console.warn(`[amicode/bridge] ${f} sync failed:`, syncErr); + } + } + console.log("[amicode/bridge] synced content dirs + markdown to installed extension"); } // ── Apply VS Code settings ── From 2416205504b7d4c41fca36d351471623a243ce71 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 17 Aug 2026 11:03:19 +0200 Subject: [PATCH 6/7] fix: promote VS Code settings step from optional to required in migrate-to-armonia skill --- .../skills/migrate-to-armonia/SKILL.md | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/extension/skills/migrate-to-armonia/SKILL.md b/packages/extension/skills/migrate-to-armonia/SKILL.md index 3e62a2ff..5b143888 100644 --- a/packages/extension/skills/migrate-to-armonia/SKILL.md +++ b/packages/extension/skills/migrate-to-armonia/SKILL.md @@ -159,17 +159,34 @@ bash ~/armonia/repos/amicode/tools/retire-amico-symlinks.sh This removes all symlinks, moves config files from `~/.amico/` to `data/config/` (making it authoritative), and removes `~/.amico/` if empty. -### Step 5: VS Code settings (optional, manual) +### Step 5: VS Code settings -If the migration script's VS Code scan was skipped (non-interactive) or the user wants to revisit: +After migration, stale paths in VS Code settings will silently break skill loading, +the dev asset pipeline, or the opencode binary resolution. This step is **required**. -The settings to check in `~/Library/Application Support/Code/User/settings.json` (macOS) or `~/.config/Code/User/settings.json` (Linux): +Read the user's settings file: -- `amicode.opencodeBinary` — should point to the binary under `~/armonia/repos/amicode/` if it moved -- `amicode.devAssetRoot` — same -- `amicode.skillRoots` — the code default is now `~/armonia/repos/packages/`; if explicitly set, update it +```bash +# macOS +cat ~/Library/Application\ Support/Code/User/settings.json | grep -i "amicode\|armonia\|harmoniqs" +# Linux +# cat ~/.config/Code/User/settings.json | grep -i "amicode\|armonia\|harmoniqs" +``` + +Check each of these keys (if present): + +| Key | Old value (stale) | Correct value | +|-----|-------------------|---------------| +| `amicode.opencodeBinary` | `~/harmoniqs/amicode/…` or `~/_dev/harmoniqs/amicode/…` | `~/armonia/repos/amicode/…` (same relative suffix) | +| `amicode.devAssetRoot` | `~/harmoniqs/amicode/packages/extension` | `~/armonia/repos/amicode/packages/extension` | +| `amicode.skillRoots` | `~/harmoniqs/packages` | **Remove the key entirely** — the code default is now `~/armonia/repos/packages/`. Only set it explicitly if the user has a non-standard layout. | + +For each stale entry found: +1. Show the user the current value and what it should be +2. Ask for confirmation before rewriting +3. Apply the edit (or removal) only after explicit go-ahead -Show the user the current values and suggest corrections. Never rewrite without asking. +If no Amicode-related keys exist in settings, report "no overrides found — code defaults are correct" and move on. --- From 2fa568b91aca875cd374ba69b6545f207e9d7c10 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 17 Aug 2026 11:50:48 +0200 Subject: [PATCH 7/7] feat: centralized paths.ts + rewrite 16 consumers + bootstrap-amicode.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add packages/amico-run/src/paths.ts: single source of truth for all Amicode filesystem paths, with resolution chain ($AMICODE_ROOT → ~/.amicode/ → ~/.amico/ fallback → create ~/.amicode/) - Re-export from package index - Rewrite 15 consumer files to import from paths.ts instead of hand-rolling os.homedir()/.amico/ paths - Add tools/bootstrap-amicode.sh: fresh-machine setup for the canonical ~/.amicode/ + ~/armonia/{repos,vaults}/ layout (three tiers: minimal/standard/full) Part of #386 --- packages/amico-run/src/authoring.ts | 5 +- packages/amico-run/src/coordination_ledger.ts | 5 +- packages/amico-run/src/device_graph.ts | 4 +- packages/amico-run/src/fleet_registry.ts | 5 +- packages/amico-run/src/index.ts | 8 + packages/amico-run/src/ledger.ts | 6 +- packages/amico-run/src/mounts.ts | 10 +- packages/amico-run/src/pasqal_devices.ts | 5 +- packages/amico-run/src/pasqal_launch.ts | 5 +- packages/amico-run/src/pasqal_verb.ts | 4 +- packages/amico-run/src/paths.ts | 243 ++++++++++++++++++ packages/amico-run/src/profile_verb.ts | 6 +- packages/amico-run/src/remote_config.ts | 5 +- packages/amico-run/src/repertoire.ts | 4 +- packages/amico-run/src/run_dir.ts | 4 +- packages/amico-run/src/solver_mode.ts | 4 +- packages/amico-run/src/vault_query.ts | 4 +- tools/bootstrap-amicode.sh | 183 +++++++++++++ 18 files changed, 468 insertions(+), 42 deletions(-) create mode 100644 packages/amico-run/src/paths.ts create mode 100644 tools/bootstrap-amicode.sh diff --git a/packages/amico-run/src/authoring.ts b/packages/amico-run/src/authoring.ts index 788046eb..a4459da7 100644 --- a/packages/amico-run/src/authoring.ts +++ b/packages/amico-run/src/authoring.ts @@ -5,8 +5,7 @@ // defaults (public base ∪ support set) so a bare-but-spec'd dev invocation // still gates sanely. $AMICO_AUTHORING_FILE overrides the path (tests). import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; +import { authoringFile as _authoringFile } from "./paths.js"; // NOTE (spec-20260704-113005 §3): session prep ALSO writes an additive // `skills: [{source: "library"|"package", package?, name, description, path}]` @@ -37,7 +36,7 @@ function defaults(): AuthoringConfig { export function authoringFile(): string { const env = process.env.AMICO_AUTHORING_FILE; if (env && env.trim() !== "") return env; - return join(homedir(), ".amico", "authoring", "authoring.json"); + return _authoringFile(); } export function readAuthoring(): { config: AuthoringConfig; warning?: string } { diff --git a/packages/amico-run/src/coordination_ledger.ts b/packages/amico-run/src/coordination_ledger.ts index 6b557578..c0933251 100644 --- a/packages/amico-run/src/coordination_ledger.ts +++ b/packages/amico-run/src/coordination_ledger.ts @@ -4,12 +4,9 @@ import { createHash } from "node:crypto"; import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; -import { homedir } from "node:os"; import path from "node:path"; +import { claimsFile } from "./paths.js"; -function claimsFile(): string { - return process.env.AMICO_CLAIMS_FILE ?? path.join(homedir(), ".amico", "ledger", "claims.jsonl"); -} function appendClaimLine(claim: Claim): void { const file = claimsFile(); mkdirSync(path.dirname(file), { recursive: true }); diff --git a/packages/amico-run/src/device_graph.ts b/packages/amico-run/src/device_graph.ts index 3375f3a9..2ec700a6 100644 --- a/packages/amico-run/src/device_graph.ts +++ b/packages/amico-run/src/device_graph.ts @@ -12,9 +12,9 @@ // Loaders never throw: a missing/corrupt graph or state degrades to an empty view, // exactly like repertoire.ts's loaders degrade to an empty repertoire. import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; import { parse as parseToml } from "smol-toml"; +import { devicesDir } from "./paths.js"; /** The single status enum — node state, evaluate() verdict, and the per-qubit * rollup all use it (no divergent vocabularies). */ @@ -571,7 +571,7 @@ export function releaseDecision( export function deviceRoot(): string { const env = process.env.AMICO_DEVICE_DIR; if (env && env.trim() !== "") return env; - return join(homedir(), ".amico", "devices"); + return devicesDir(); } export interface DeviceLoad { diff --git a/packages/amico-run/src/fleet_registry.ts b/packages/amico-run/src/fleet_registry.ts index a87081ee..75610d63 100644 --- a/packages/amico-run/src/fleet_registry.ts +++ b/packages/amico-run/src/fleet_registry.ts @@ -34,9 +34,10 @@ // `retierEventFor` are that mapping, kept pure and here so the CLI cannot invent a // fourth reading of it. import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; -import { homedir, hostname } from "node:os"; +import { hostname } from "node:os"; import { join } from "node:path"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; +import { fleetDir } from "./paths.js"; // ── §3.2 states ────────────────────────────────────────────────────────────────── /** The six registry states. Entered when: @@ -887,7 +888,7 @@ export function signalFromToml(text: string): { ok: true; signal: FleetSignal } /** The registry root. Precedence: explicit argument (tests, `--root`) → `$AMICO_FLEET_DIR` * → `~/.amico/ops/fleet` (§3.2). Mirrors ledger.ts's single-env idiom. */ export function fleetRoot(explicit?: string): string { - return explicit || process.env.AMICO_FLEET_DIR || join(homedir(), ".amico", "ops", "fleet"); + return explicit || fleetDir(); } export function recordPath(root: string, session_id: string): string { diff --git a/packages/amico-run/src/index.ts b/packages/amico-run/src/index.ts index 2470e084..b3a60207 100644 --- a/packages/amico-run/src/index.ts +++ b/packages/amico-run/src/index.ts @@ -1,3 +1,11 @@ +export { + baseDir, isCanonicalLayout, isLegacyLayout, _resetBaseDir, + runsRoot, problemsRoot, juliaProject, ledgerDir, ledgerFile, claimsFile, + authoringDir, authoringFile, devicesDir, libraryDir, opsDir, + fleetDir, configFile, profileFile, pasqalConfigFile, connectionsFile, + labTomlFile, mountsTomlFile, vaultsRoot, teamVaultDir, + catalogPulsesDir, profilesVaultDir, teamSkillsDir, reposRoot, +} from "./paths.js"; export * from "./types.js"; export * from "./estimate.js"; export * from "./telemetry.js"; diff --git a/packages/amico-run/src/ledger.ts b/packages/amico-run/src/ledger.ts index 0467d290..ff121a1e 100644 --- a/packages/amico-run/src/ledger.ts +++ b/packages/amico-run/src/ledger.ts @@ -18,9 +18,9 @@ // cross-process guarantee is exercised by a real-subprocess concurrency test at the // `ledger` verb layer (ledger_verb.test.ts), where the built CLI exists. import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname } from "node:path"; import { validate } from "@amicode/schema"; +import { ledgerFile } from "./paths.js"; /** POSIX minimum PIPE_BUF; O_APPEND writes at or under this size are atomic on * Linux (a single `write(2)` never interleaves with another). */ @@ -293,7 +293,7 @@ export type LedgerRecord = /** The ledger file path: `$AMICO_LEDGER` override, else `~/.amico/ledger/runs.jsonl`. */ export function ledgerPath(): string { - return process.env.AMICO_LEDGER || join(homedir(), ".amico", "ledger", "runs.jsonl"); + return ledgerFile(); } /** Append one record as a single JSONL line. Validates against the `ledger-record` diff --git a/packages/amico-run/src/mounts.ts b/packages/amico-run/src/mounts.ts index a3cf5a0f..a7bfd2b4 100644 --- a/packages/amico-run/src/mounts.ts +++ b/packages/amico-run/src/mounts.ts @@ -41,9 +41,9 @@ // House style (mirrors repertoire.ts): never-throwing loaders (a missing/corrupt // vault or manifest degrades to a warning, never a throw) + pure functions. import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; -import { homedir } from "node:os"; import { basename, join } from "node:path"; import { parse as parseToml } from "smol-toml"; +import { vaultsRoot, mountsTomlFile } from "./paths.js"; /** One resolved Armonia vault mount. `writable` is the effective posture after the * kind default + any manifest override. */ @@ -80,14 +80,10 @@ function defaultWritable(kind: string): boolean { // ── env-seam defaults ───────────────────────────────────────────────────────── function defaultVaultsRoot(): string { - const env = process.env.AMICO_VAULTS_ROOT; - if (env && env.trim() !== "") return env; - return join(homedir(), ".amico", "vaults"); + return vaultsRoot(); } function defaultMountsToml(): string { - const env = process.env.AMICO_MOUNTS_TOML; - if (env && env.trim() !== "") return env; - return join(homedir(), ".amico", "mounts.toml"); + return mountsTomlFile(); } // ── manifest (`mounts.toml`) ───────────────────────────────────────────────────── diff --git a/packages/amico-run/src/pasqal_devices.ts b/packages/amico-run/src/pasqal_devices.ts index 98bcb440..bd96e245 100644 --- a/packages/amico-run/src/pasqal_devices.ts +++ b/packages/amico-run/src/pasqal_devices.ts @@ -12,8 +12,7 @@ // a poisoned "token", anything — has no path into our output. import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; +import { connectionsFile } from "./paths.js"; export const PASQAL_CONNECTION_ID = "pasqal-cloud"; @@ -35,7 +34,7 @@ export type DeviceTier = "free" | "non-free"; export function connectionsCacheFile(env: NodeJS.ProcessEnv = process.env): string { const v = env.AMICODE_CONNECTIONS_FILE; if (v && v.trim() !== "") return v; - return join(homedir(), ".amico", "connections.json"); + return connectionsFile(); } /** Default-deny classification, case-insensitive on the identifier. */ diff --git a/packages/amico-run/src/pasqal_launch.ts b/packages/amico-run/src/pasqal_launch.ts index 1012205f..e4abb0c4 100644 --- a/packages/amico-run/src/pasqal_launch.ts +++ b/packages/amico-run/src/pasqal_launch.ts @@ -8,9 +8,10 @@ // child env is built from scratch (never a process.env spread). import { spawn } from "node:child_process"; import { accessSync, constants as fsConstants, existsSync, readFileSync } from "node:fs"; -import { constants as osConstants, homedir } from "node:os"; +import { constants as osConstants } from "node:os"; import { delimiter, join, resolve } from "node:path"; import { ConfigError } from "./types.js"; +import { pasqalConfigFile } from "./paths.js"; export interface PasqalCredentials { projectId: string; @@ -22,7 +23,7 @@ export interface PasqalCredentials { export function pasqalCredentialFile(env: NodeJS.ProcessEnv = process.env): string { const v = env.AMICO_PASQAL_FILE; if (v && v.trim() !== "") return v; - return join(homedir(), ".amico", "pasqal.json"); + return pasqalConfigFile(); } /** Read + shape-check the credential file. Distinct, actionable, TOKEN-FREE errors diff --git a/packages/amico-run/src/pasqal_verb.ts b/packages/amico-run/src/pasqal_verb.ts index 301f279d..2d7cf169 100644 --- a/packages/amico-run/src/pasqal_verb.ts +++ b/packages/amico-run/src/pasqal_verb.ts @@ -24,9 +24,9 @@ // token appears in the digest, the JSON output, or the launcher argv. import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { opsDir } from "./paths.js"; import { pulseSha256, readDevicePathStatus, @@ -61,7 +61,7 @@ function flagValue(argv: string[], name: string): string | undefined { function amicodeOpsDir(env: NodeJS.ProcessEnv): string { const v = env.AMICODE_OPS_DIR; if (v && v.trim() !== "") return v; - return join(homedir(), ".amico", "amicode"); + return opsDir(); } /** $AMICO_PASQAL_CONNECTOR overrides; default is the staged submit connector. */ diff --git a/packages/amico-run/src/paths.ts b/packages/amico-run/src/paths.ts new file mode 100644 index 00000000..c5310146 --- /dev/null +++ b/packages/amico-run/src/paths.ts @@ -0,0 +1,243 @@ +// Centralized path resolution for Amicode application state. +// +// Resolution order (baseDir): +// 1. $AMICODE_ROOT env override (for tests / unusual layouts) +// 2. ~/.amicode/ exists → canonical new layout +// 3. ~/.amico/ exists → legacy fallback (pre-migration users) +// 4. ~/.amicode/ (default for fresh installs — created on first write by caller) +// +// Vaults are workspace-level (not app state) and resolve separately: +// 1. $AMICO_VAULTS_ROOT env override +// 2. ~/armonia/vaults/ exists → canonical +// 3. /vaults/ → legacy fallback +// +// Each sub-path resolver also respects its own $AMICO_* env override (for +// test isolation via execFileSync bundles) — the override wins unconditionally. +// +// This module is the SINGLE SOURCE OF TRUTH for path policy. Every consumer +// that formerly did `join(homedir(), ".amico", ...)` should import from here. + +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// ── base resolution ────────────────────────────────────────────────────────── + +let _cachedBase: string | undefined; + +/** + * The root directory for all Amicode application state. + * Cached for the process lifetime (path layout doesn't change mid-run). + */ +export function baseDir(): string { + if (_cachedBase !== undefined) return _cachedBase; + + const explicit = process.env.AMICODE_ROOT; + if (explicit && explicit.trim() !== "") { + _cachedBase = explicit; + return _cachedBase; + } + + const canonical = join(homedir(), ".amicode"); + if (existsSync(canonical)) { + _cachedBase = canonical; + return _cachedBase; + } + + const legacy = join(homedir(), ".amico"); + if (existsSync(legacy)) { + _cachedBase = legacy; + return _cachedBase; + } + + // Fresh install: canonical path (created on first write by caller) + _cachedBase = canonical; + return _cachedBase; +} + +/** Whether the resolved base is the new canonical layout (~/.amicode/). */ +export function isCanonicalLayout(): boolean { + return baseDir() === join(homedir(), ".amicode"); +} + +/** Whether we're on the legacy layout (~/.amico/). */ +export function isLegacyLayout(): boolean { + return baseDir() === join(homedir(), ".amico"); +} + +/** Reset the cached base (for tests only). */ +export function _resetBaseDir(): void { + _cachedBase = undefined; +} + +// ── sub-path resolvers ──────────────────────────────────────────────────────── +// Each respects its own env override first, then derives from baseDir(). + +/** Runs root: /runs// */ +export function runsRoot(labId: string): string { + return join(baseDir(), "runs", labId); +} + +/** Problems root: /problems/ */ +export function problemsRoot(): string { + return join(baseDir(), "problems"); +} + +/** Julia project: /env/julia/ (canonical) or /julia/ (legacy) */ +export function juliaProject(): string { + const base = baseDir(); + if (isLegacyLayout()) return join(base, "julia"); + return join(base, "env", "julia"); +} + +/** Ledger directory: /ledger/ */ +export function ledgerDir(): string { + return join(baseDir(), "ledger"); +} + +/** Ledger file: $AMICO_LEDGER or /ledger/runs.jsonl */ +export function ledgerFile(): string { + const env = process.env.AMICO_LEDGER; + if (env && env.trim() !== "") return env; + return join(ledgerDir(), "runs.jsonl"); +} + +/** Claims file: $AMICO_CLAIMS_FILE or /ledger/claims.jsonl */ +export function claimsFile(): string { + const env = process.env.AMICO_CLAIMS_FILE; + if (env && env.trim() !== "") return env; + return join(ledgerDir(), "claims.jsonl"); +} + +/** Authoring directory: /authoring/ */ +export function authoringDir(): string { + return join(baseDir(), "authoring"); +} + +/** Authoring file: /authoring/authoring.json */ +export function authoringFile(): string { + return join(authoringDir(), "authoring.json"); +} + +/** Devices directory: /devices/ */ +export function devicesDir(): string { + return join(baseDir(), "devices"); +} + +/** Library directory: /library/ */ +export function libraryDir(): string { + return join(baseDir(), "library"); +} + +/** + * Ops directory (entitlements, solver-mode, onboarding state): + * $AMICODE_OPS_DIR or /ops/ (canonical) or /amicode/ (legacy) + */ +export function opsDir(): string { + const env = process.env.AMICODE_OPS_DIR; + if (env && env.trim() !== "") return env; + const base = baseDir(); + if (isLegacyLayout()) return join(base, "amicode"); + return join(base, "ops"); +} + +/** + * Fleet directory: + * $AMICO_FLEET_DIR or /fleet/ (canonical) or /ops/fleet/ (legacy) + */ +export function fleetDir(): string { + const env = process.env.AMICO_FLEET_DIR; + if (env && env.trim() !== "") return env; + const base = baseDir(); + if (isLegacyLayout()) return join(base, "ops", "fleet"); + return join(base, "fleet"); +} + +// ── config files ────────────────────────────────────────────────────────────── +// Canonical layout puts configs in /config/. +// Legacy layout has them loose at /. + +/** Resolve a config file path by name. */ +export function configFile(name: string): string { + const base = baseDir(); + if (isLegacyLayout()) return join(base, name); + return join(base, "config", name); +} + +/** profile.json */ +export function profileFile(): string { + return configFile("profile.json"); +} + +/** cloud.json: cloud API credentials */ +export function cloudConfigFile(): string { + return configFile("cloud.json"); +} + +/** pasqal.json: Pasqal credentials */ +export function pasqalConfigFile(): string { + return configFile("pasqal.json"); +} + +/** connections.json: connections status cache */ +export function connectionsFile(): string { + return configFile("connections.json"); +} + +/** lab.toml: hardware lab profile */ +export function labTomlFile(): string { + return configFile("lab.toml"); +} + +/** mounts.toml: vault mount manifest */ +export function mountsTomlFile(): string { + const env = process.env.AMICO_MOUNTS_TOML; + if (env && env.trim() !== "") return env; + return configFile("mounts.toml"); +} + +// ── vaults (workspace-level, lives under ~/armonia/) ───────────────────────── + +/** + * Vaults root: $AMICO_VAULTS_ROOT or ~/armonia/vaults/ (canonical) or /vaults/ (legacy). + * Vaults are workspace content (shared knowledge), not app state — they live + * under ~/armonia/ in the canonical layout. + */ +export function vaultsRoot(): string { + const env = process.env.AMICO_VAULTS_ROOT; + if (env && env.trim() !== "") return env; + + const armonia = join(homedir(), "armonia", "vaults"); + if (existsSync(armonia)) return armonia; + + return join(baseDir(), "vaults"); +} + +/** The team vault (armonissima) */ +export function teamVaultDir(): string { + return join(vaultsRoot(), "armonissima"); +} + +/** Catalog pulses directory (under team vault) */ +export function catalogPulsesDir(): string { + return join(teamVaultDir(), "catalog", "pulses"); +} + +/** Profiles directory (under team vault) */ +export function profilesVaultDir(): string { + const env = process.env.AMICO_PROFILES_DIR; + if (env && env.trim() !== "") return env; + return join(teamVaultDir(), "profiles"); +} + +/** Skills directory (internal, under team vault) */ +export function teamSkillsDir(): string { + return join(teamVaultDir(), "skills"); +} + +// ── repos (workspace layer) ────────────────────────────────────────────────── + +/** Repos root: ~/armonia/repos/ */ +export function reposRoot(): string { + return join(homedir(), "armonia", "repos"); +} diff --git a/packages/amico-run/src/profile_verb.ts b/packages/amico-run/src/profile_verb.ts index 6d39feba..3c301c52 100644 --- a/packages/amico-run/src/profile_verb.ts +++ b/packages/amico-run/src/profile_verb.ts @@ -26,12 +26,12 @@ // `base` applies when it is DISPATCHED, not when it is WORN. `--mode spool-up` // enforces that and reports the ignored value rather than silently dropping it. import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { parse as parseToml } from "smol-toml"; import { TASK_TYPES } from "./ledger.js"; import { FRONTIER_MODELS, LADDER } from "./ledger_dispatch.js"; import type { VerbResult } from "./verbs.js"; +import { profilesVaultDir, opsDir } from "./paths.js"; // ── vocabularies (closed sets, extensible only by schema revision — §2.1) ───────── const BASES = ["resident", "executor", "headless"] as const; @@ -108,7 +108,7 @@ const strList = (v: unknown): string[] | undefined => * the extension looks for internal library skills (DEFAULT_LIBRARY_ROOTS). Overridable * per-flag and per-env so CI and tests point at a fixture tree. */ function profilesDir(argv: string[]): string { - return flagValue(argv, "--profiles-dir") ?? process.env.AMICO_PROFILES_DIR ?? join(homedir(), ".amico", "vaults", "armonissima", "profiles"); + return flagValue(argv, "--profiles-dir") ?? process.env.AMICO_PROFILES_DIR ?? profilesVaultDir(); } function siblingDir(argv: string[], flag: string, env: string, name: string): string { const explicit = flagValue(argv, flag) ?? process.env[env]; @@ -149,7 +149,7 @@ function readEntitlements(argv: string[], warnings: string[]): { codes: string[] if (inline !== undefined) { return { codes: inline.split(",").map((c) => c.trim()).filter((c) => c.length > 0), source: "flag/env" }; } - const dir = flagValue(argv, "--entitlements-dir") ?? process.env.AMICO_ENTITLEMENTS_DIR ?? join(homedir(), ".amico", "amicode"); + const dir = flagValue(argv, "--entitlements-dir") ?? process.env.AMICO_ENTITLEMENTS_DIR ?? opsDir(); const file = join(dir, "entitlements.toml"); if (!existsSync(file)) return { codes: [], source: "none (no entitlements.toml — public only)" }; try { diff --git a/packages/amico-run/src/remote_config.ts b/packages/amico-run/src/remote_config.ts index 00a48ee5..e615ef95 100644 --- a/packages/amico-run/src/remote_config.ts +++ b/packages/amico-run/src/remote_config.ts @@ -4,9 +4,8 @@ // message or log line (llm_creds.mjs stance — the secret never enters // amico's surfaces beyond the Authorization header itself). import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; import { ConfigError } from "./types.js"; +import { cloudConfigFile as _cloudConfigFile } from "./paths.js"; export interface RemoteConfig { baseUrl: string; // e.g. https://solves.staging.harmoniqs.co (no trailing slash) @@ -17,7 +16,7 @@ export interface RemoteConfig { export function cloudConfigFile(env: NodeJS.ProcessEnv = process.env): string { const v = env.AMICO_CLOUD_FILE; if (v && v.trim() !== "") return v; - return join(homedir(), ".amico", "cloud.json"); + return _cloudConfigFile(); } /** Resolution order: AMICO_CLOUD_URL+AMICO_CLOUD_TOKEN env pair → cloud.json. diff --git a/packages/amico-run/src/repertoire.ts b/packages/amico-run/src/repertoire.ts index 3c5bd4cb..595d7de6 100644 --- a/packages/amico-run/src/repertoire.ts +++ b/packages/amico-run/src/repertoire.ts @@ -11,8 +11,8 @@ // record missing a discriminating field (id/platform/gate/fidelity) is skipped, // not fatal. import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; +import { catalogPulsesDir as _catalogPulsesDir } from "./paths.js"; import { parse as parseToml } from "smol-toml"; /** A flat `metadata.toml` pulse record (amico-catalog Phase-0 schema). Known keys @@ -41,7 +41,7 @@ export interface PulseRecord { export function catalogPulsesDir(): string { const env = process.env.AMICO_CATALOG_DIR; if (env && env.trim() !== "") return env; - return join(homedir(), ".amico", "vaults", "armonissima", "catalog", "pulses"); + return _catalogPulsesDir(); } function num(v: unknown): number | undefined { diff --git a/packages/amico-run/src/run_dir.ts b/packages/amico-run/src/run_dir.ts index 5c5db6f1..02ee11e5 100644 --- a/packages/amico-run/src/run_dir.ts +++ b/packages/amico-run/src/run_dir.ts @@ -1,8 +1,8 @@ import { existsSync, writeFileSync, renameSync, appendFileSync, symlinkSync, rmSync } from "node:fs"; import { randomBytes } from "node:crypto"; -import { homedir } from "node:os"; import { join, dirname, basename, resolve } from "node:path"; import { ConfigError, type RunStatus } from "./types.js"; +import { runsRoot } from "./paths.js"; const ID_RE = /^[a-z0-9][a-z0-9_-]*$/; @@ -19,7 +19,7 @@ export function deriveLabId(lab: string): string { } export function defaultRunsRoot(labId: string): string { - return join(homedir(), ".amico", "runs", labId); + return runsRoot(labId); } export function generateRunId(runsRoot: string, now = new Date()): string { diff --git a/packages/amico-run/src/solver_mode.ts b/packages/amico-run/src/solver_mode.ts index 1c44eb8a..bd91aa7c 100644 --- a/packages/amico-run/src/solver_mode.ts +++ b/packages/amico-run/src/solver_mode.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; +import { opsDir } from "./paths.js"; // ============================================================================ // The selected solver, read from the extension's solver-mode.json contract. @@ -22,7 +22,7 @@ export type SolverMode = "piccolo" | "hp"; * the file the extension wrote. */ function amicodeOpsDir(env: NodeJS.ProcessEnv): string { const v = env.AMICODE_OPS_DIR; - return v && v.trim() !== "" ? v : join(homedir(), ".amico", "amicode"); + return v && v.trim() !== "" ? v : opsDir(); } export function solverModeFile(env: NodeJS.ProcessEnv = process.env): string { diff --git a/packages/amico-run/src/vault_query.ts b/packages/amico-run/src/vault_query.ts index f08b80a7..d22f9a1f 100644 --- a/packages/amico-run/src/vault_query.ts +++ b/packages/amico-run/src/vault_query.ts @@ -11,8 +11,8 @@ // of scalar fields the ranker/filters need (type/platform/gate/tags), extracted // by regex, not a full YAML engine. import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; +import { teamVaultDir } from "./paths.js"; /** A vault note projected for retrieval. `body` is the markdown after the * frontmatter; `title` is the first `# ` heading (else the filename). `mount` is @@ -37,7 +37,7 @@ export interface NoteRecord { export function vaultDir(): string { const env = process.env.AMICO_VAULT_DIR; if (env && env.trim() !== "") return env; - return join(homedir(), ".amico", "vaults", "armonissima"); + return teamVaultDir(); } /** The note folders the retrieval searches — the knowledge-graph nucleus diff --git a/tools/bootstrap-amicode.sh b/tools/bootstrap-amicode.sh new file mode 100644 index 00000000..d054c52d --- /dev/null +++ b/tools/bootstrap-amicode.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +set -euo pipefail + +# bootstrap-amicode.sh +# Fresh-machine setup for the canonical Amicode layout: +# +# ~/.amicode/ App state (dotdir, product-named) +# config/ profile.json, cloud.json, pasqal.json, connections.json, lab.toml, mounts.toml +# env/julia/ Provisioned Julia project +# problems/ Problem workspaces +# runs/ Solve outputs +# ledger/ runs.jsonl, claims.jsonl +# devices/ Device state +# library/ Uploaded papers +# authoring/ authoring.json +# ops/ Entitlements, solver-mode, onboarding state +# fleet/ Fleet topology +# +# ~/armonia/ Workspace layer (visible, not hidden) +# repos/packages/ Julia libraries (Piccolo.jl, ...) +# repos/demos/ Demo galleries (atoms-demo, ...) +# repos/ Apps, forks, projects (amicode, ...) +# vaults/ Obsidian vaults (armonia-jj-lee, armonissima, ...) +# +# Usage: +# bootstrap-amicode.sh [--minimal|--standard|--full] +# +# --minimal layout dirs only (extension users who never touch source) +# --standard + public packages and demos via plain git clone (default) +# --full + private Harmoniqs repos via `gh` (requires gh auth with access) +# +# Curl-able: +# bash <(curl -fsSL https://raw.githubusercontent.com/harmoniqs/amicode/main/tools/bootstrap-amicode.sh) +# +# Idempotent: existing dirs/clones are skipped. + +AMICODE="${HOME}/.amicode" +ARMONIA="${HOME}/armonia" +TIER="standard" + +for arg in "$@"; do + case "$arg" in + --minimal|--standard|--full) TIER="${arg#--}" ;; + -h|--help) + sed -n '2,36p' "$0"; exit 0 ;; + *) echo "unknown arg: $arg" >&2; exit 64 ;; + esac +done + +# Public Julia libraries (registered packages; plain git clone works). +PUBLIC_PACKAGES=( + Piccolo.jl + NamedTrajectories.jl + DirectTrajOpt.jl +) + +# Public demo galleries. +PUBLIC_DEMOS=( + atoms-demo + fluxonium-demo + ions +) + +# Private Harmoniqs repos (cloned with gh; requires org access). +PRIVATE_PACKAGES=( + Piccolissimo.jl +) +PRIVATE_APPS=( + amicode +) + +# =================================================================== +main() { + echo "==> bootstrap-amicode tier=${TIER}" + echo + + # Check if user needs migration instead of bootstrap + if [[ -d "${HOME}/.amico" && ! -L "${HOME}/.amico" ]]; then + echo " NOTE: existing ~/.amico/ detected." + echo " Run tools/migrate-to-amicode.sh to migrate your state." + echo " (bootstrap creates the skeleton alongside it — safe to proceed)" + echo + fi + if [[ -d "${ARMONIA}/data" ]]; then + echo " NOTE: existing ~/armonia/data/ detected (old layout)." + echo " Run tools/migrate-to-amicode.sh to consolidate." + echo + fi + + make_layout + + case "$TIER" in + minimal) echo "tier=minimal — no repos cloned" ;; + standard) clone_public ;; + full) clone_public; clone_private ;; + esac + + echo + echo "==> Done." + echo " App state: ~/.amicode/{config,env/julia,problems,runs,ledger,...}" + echo " Workspace: ~/armonia/{repos/{packages,demos,...}, vaults/}" +} + +# ------------------------------------------------------------------- +make_layout() { + echo "--- layout ---" + + # App state + mkdir -p "${AMICODE}/config" \ + "${AMICODE}/env/julia" \ + "${AMICODE}/problems" \ + "${AMICODE}/runs" \ + "${AMICODE}/ledger" \ + "${AMICODE}/devices" \ + "${AMICODE}/library" \ + "${AMICODE}/authoring" \ + "${AMICODE}/ops" \ + "${AMICODE}/fleet" + echo " ~/.amicode/{config,env/julia,problems,runs,ledger,devices,library,authoring,ops,fleet} ready" + + # Workspace + mkdir -p "${ARMONIA}/repos/packages" \ + "${ARMONIA}/repos/demos" \ + "${ARMONIA}/vaults" + echo " ~/armonia/{repos/{packages,demos}, vaults/} ready" +} + +# ------------------------------------------------------------------- +clone_public() { + echo "--- clone (public) ---" + for repo in "${PUBLIC_PACKAGES[@]}"; do + clone_or_update "https://github.com/harmoniqs/${repo}.git" "${ARMONIA}/repos/packages/${repo}" + done + for repo in "${PUBLIC_DEMOS[@]}"; do + clone_or_update "https://github.com/harmoniqs/${repo}.git" "${ARMONIA}/repos/demos/${repo}" + done +} + +# ------------------------------------------------------------------- +clone_private() { + echo "--- clone (private, via gh) ---" + if ! command -v gh >/dev/null 2>&1; then + echo " gh not installed — skipping private tier"; return 0 + fi + if ! gh auth status >/dev/null 2>&1; then + echo " gh not authenticated — skipping private tier (run: gh auth login)"; return 0 + fi + for repo in "${PRIVATE_PACKAGES[@]}"; do + gh_clone_or_update "harmoniqs/${repo}" "${ARMONIA}/repos/packages/${repo}" + done + for repo in "${PRIVATE_APPS[@]}"; do + gh_clone_or_update "harmoniqs/${repo}" "${ARMONIA}/repos/${repo}" + done +} + +# ------------------------------------------------------------------- +clone_or_update() { + local url="$1" dest="$2" + if [[ -d "${dest}/.git" ]]; then + echo " (exists) $(basename "$dest") — pulling" + git -C "$dest" pull --ff-only 2>/dev/null || echo " (pull skipped: not fast-forwardable)" + elif [[ -e "$dest" ]]; then + echo " (exists, not a git repo — left alone) $(basename "$dest")" + else + echo " clone $(basename "$dest")" + git clone "$url" "$dest" + fi +} + +gh_clone_or_update() { + local repo="$1" dest="$2" + if [[ -d "${dest}/.git" ]]; then + echo " (exists) $(basename "$dest") — pulling" + git -C "$dest" pull --ff-only 2>/dev/null || echo " (pull skipped: not fast-forwardable)" + elif [[ -e "$dest" ]]; then + echo " (exists, not a git repo — left alone) $(basename "$dest")" + else + echo " clone $repo" + gh repo clone "$repo" "$dest" + fi +} + +main