chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_DIR="$(pwd)"
|
||||
FORCE=0
|
||||
QUALITY_TEMPLATE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--force)
|
||||
FORCE=1
|
||||
shift
|
||||
;;
|
||||
--quality-template)
|
||||
QUALITY_TEMPLATE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
TARGET_DIR="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -d "$TARGET_DIR" ]]; then
|
||||
echo "[mosaic] Target directory does not exist: $TARGET_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
TEMPLATE_ROOT="$MOSAIC_HOME/templates/repo"
|
||||
|
||||
if [[ ! -d "$TEMPLATE_ROOT" ]]; then
|
||||
echo "[mosaic] Missing templates at $TEMPLATE_ROOT" >&2
|
||||
echo "[mosaic] Install or refresh framework: ~/.config/mosaic/install.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$TARGET_DIR/.mosaic" "$TARGET_DIR/scripts/agent"
|
||||
mkdir -p "$TARGET_DIR/.mosaic/orchestrator" "$TARGET_DIR/.mosaic/orchestrator/logs" "$TARGET_DIR/.mosaic/orchestrator/results"
|
||||
|
||||
copy_file() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
|
||||
if [[ -f "$dst" && "$FORCE" -ne 1 ]]; then
|
||||
echo "[mosaic] Skip existing: $dst"
|
||||
return
|
||||
fi
|
||||
|
||||
cp "$src" "$dst"
|
||||
echo "[mosaic] Wrote: $dst"
|
||||
}
|
||||
|
||||
copy_file "$TEMPLATE_ROOT/.mosaic/README.md" "$TARGET_DIR/.mosaic/README.md"
|
||||
copy_file "$TEMPLATE_ROOT/.mosaic/repo-hooks.sh" "$TARGET_DIR/.mosaic/repo-hooks.sh"
|
||||
copy_file "$TEMPLATE_ROOT/.mosaic/quality-rails.yml" "$TARGET_DIR/.mosaic/quality-rails.yml"
|
||||
copy_file "$TEMPLATE_ROOT/.mosaic/orchestrator/config.json" "$TARGET_DIR/.mosaic/orchestrator/config.json"
|
||||
copy_file "$TEMPLATE_ROOT/.mosaic/orchestrator/tasks.json" "$TARGET_DIR/.mosaic/orchestrator/tasks.json"
|
||||
copy_file "$TEMPLATE_ROOT/.mosaic/orchestrator/state.json" "$TARGET_DIR/.mosaic/orchestrator/state.json"
|
||||
copy_file "$TEMPLATE_ROOT/.mosaic/orchestrator/matrix_state.json" "$TARGET_DIR/.mosaic/orchestrator/matrix_state.json"
|
||||
copy_file "$TEMPLATE_ROOT/.mosaic/orchestrator/logs/.gitkeep" "$TARGET_DIR/.mosaic/orchestrator/logs/.gitkeep"
|
||||
copy_file "$TEMPLATE_ROOT/.mosaic/orchestrator/results/.gitkeep" "$TARGET_DIR/.mosaic/orchestrator/results/.gitkeep"
|
||||
|
||||
for file in "$TEMPLATE_ROOT"/scripts/agent/*.sh; do
|
||||
base="$(basename "$file")"
|
||||
copy_file "$file" "$TARGET_DIR/scripts/agent/$base"
|
||||
chmod +x "$TARGET_DIR/scripts/agent/$base"
|
||||
done
|
||||
|
||||
if [[ ! -f "$TARGET_DIR/AGENTS.md" ]]; then
|
||||
cat > "$TARGET_DIR/AGENTS.md" <<'AGENTS_EOF'
|
||||
# Agent Guidelines
|
||||
|
||||
## Required Load Order
|
||||
|
||||
1. `~/.config/mosaic/SOUL.md`
|
||||
2. `~/.config/mosaic/STANDARDS.md`
|
||||
3. `~/.config/mosaic/AGENTS.md`
|
||||
4. `~/.config/mosaic/guides/E2E-DELIVERY.md`
|
||||
5. `AGENTS.md` (this file)
|
||||
6. Runtime-specific guide: `~/.config/mosaic/runtime/<runtime>/RUNTIME.md`
|
||||
7. `.mosaic/repo-hooks.sh`
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
```bash
|
||||
bash scripts/agent/session-start.sh
|
||||
bash scripts/agent/critical.sh
|
||||
bash scripts/agent/session-end.sh
|
||||
```
|
||||
|
||||
## Shared Tools
|
||||
|
||||
- Quality and orchestration guides: `~/.config/mosaic/guides/`
|
||||
- Shared automation tools: `~/.config/mosaic/tools/`
|
||||
|
||||
## Repo-Specific Notes
|
||||
|
||||
- Add project constraints and workflows here.
|
||||
- Implement hook functions in `.mosaic/repo-hooks.sh`.
|
||||
- Scratchpads are mandatory for non-trivial tasks.
|
||||
AGENTS_EOF
|
||||
echo "[mosaic] Wrote: $TARGET_DIR/AGENTS.md"
|
||||
else
|
||||
echo "[mosaic] AGENTS.md exists; add standards load order if missing"
|
||||
fi
|
||||
|
||||
echo "[mosaic] Repo bootstrap complete: $TARGET_DIR"
|
||||
echo "[mosaic] Next: edit $TARGET_DIR/.mosaic/repo-hooks.sh with project workflows"
|
||||
echo "[mosaic] Optional: apply quality tools via ~/.config/mosaic/bin/mosaic-quality-apply --template <template> --target $TARGET_DIR"
|
||||
echo "[mosaic] Optional: run orchestrator rail via ~/.config/mosaic/bin/mosaic-orchestrator-drain"
|
||||
echo "[mosaic] Optional: run detached orchestrator via bash $TARGET_DIR/scripts/agent/orchestrator-daemon.sh start"
|
||||
|
||||
if [[ -n "$QUALITY_TEMPLATE" ]]; then
|
||||
if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-quality-apply" ]]; then
|
||||
"$MOSAIC_HOME/tools/_scripts/mosaic-quality-apply" --template "$QUALITY_TEMPLATE" --target "$TARGET_DIR"
|
||||
if [[ -f "$TARGET_DIR/.mosaic/quality-rails.yml" ]]; then
|
||||
sed -i "s/^enabled:.*/enabled: true/" "$TARGET_DIR/.mosaic/quality-rails.yml"
|
||||
sed -i "s/^template:.*/template: \"$QUALITY_TEMPLATE\"/" "$TARGET_DIR/.mosaic/quality-rails.yml"
|
||||
fi
|
||||
echo "[mosaic] Applied quality tools template: $QUALITY_TEMPLATE"
|
||||
else
|
||||
echo "[mosaic] WARN: mosaic-quality-apply not found; skipping quality tools apply" >&2
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
RUNTIME="claude"
|
||||
APPLY=0
|
||||
ALL_EMPTY=0
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") [options]
|
||||
|
||||
Remove empty runtime directories created by migration/drift.
|
||||
Default mode only checks managed legacy surfaces. Use --all-empty for broader cleanup.
|
||||
|
||||
Options:
|
||||
--runtime <name> Runtime to clean (default: claude)
|
||||
--all-empty Scan all runtime directories (except protected paths)
|
||||
--apply Perform deletions (default: dry-run)
|
||||
-h, --help Show help
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--runtime)
|
||||
[[ $# -lt 2 ]] && { echo "Missing value for --runtime" >&2; exit 1; }
|
||||
RUNTIME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--all-empty)
|
||||
ALL_EMPTY=1
|
||||
shift
|
||||
;;
|
||||
--apply)
|
||||
APPLY=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$RUNTIME" in
|
||||
claude)
|
||||
TARGET_ROOT="$HOME/.claude"
|
||||
managed_roots=(
|
||||
"$HOME/.claude/agent-guides"
|
||||
"$HOME/.claude/scripts"
|
||||
"$HOME/.claude/templates"
|
||||
"$HOME/.claude/presets"
|
||||
"$HOME/.claude/skills"
|
||||
"$HOME/.claude/agents"
|
||||
"$HOME/.claude/agents.bak"
|
||||
)
|
||||
protected_roots=(
|
||||
"$HOME/.claude/.git"
|
||||
"$HOME/.claude/debug"
|
||||
"$HOME/.claude/file-history"
|
||||
"$HOME/.claude/projects"
|
||||
"$HOME/.claude/session-env"
|
||||
"$HOME/.claude/tasks"
|
||||
"$HOME/.claude/todos"
|
||||
"$HOME/.claude/plugins"
|
||||
"$HOME/.claude/statsig"
|
||||
"$HOME/.claude/logs"
|
||||
"$HOME/.claude/shell-snapshots"
|
||||
"$HOME/.claude/paste-cache"
|
||||
"$HOME/.claude/plans"
|
||||
"$HOME/.claude/ide"
|
||||
"$HOME/.claude/cache"
|
||||
)
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported runtime: $RUNTIME" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
[[ -d "$TARGET_ROOT" ]] || { echo "[mosaic-clean] Runtime dir missing: $TARGET_ROOT" >&2; exit 1; }
|
||||
|
||||
is_protected() {
|
||||
local path="$1"
|
||||
for p in "${protected_roots[@]}"; do
|
||||
[[ -e "$p" ]] || continue
|
||||
case "$path" in
|
||||
"$p"|"$p"/*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
collect_empty_dirs() {
|
||||
if [[ $ALL_EMPTY -eq 1 ]]; then
|
||||
find "$TARGET_ROOT" -depth -type d -empty
|
||||
else
|
||||
for r in "${managed_roots[@]}"; do
|
||||
[[ -d "$r" ]] || continue
|
||||
find "$r" -depth -type d -empty
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
count_candidates=0
|
||||
count_deletable=0
|
||||
|
||||
while IFS= read -r d; do
|
||||
[[ -n "$d" ]] || continue
|
||||
|
||||
count_candidates=$((count_candidates + 1))
|
||||
|
||||
# Never remove runtime root.
|
||||
[[ "$d" == "$TARGET_ROOT" ]] && continue
|
||||
|
||||
if is_protected "$d"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
count_deletable=$((count_deletable + 1))
|
||||
|
||||
if [[ $APPLY -eq 1 ]]; then
|
||||
rmdir "$d" 2>/dev/null || true
|
||||
if [[ ! -d "$d" ]]; then
|
||||
echo "[mosaic-clean] deleted: $d"
|
||||
fi
|
||||
else
|
||||
echo "[mosaic-clean] would delete: $d"
|
||||
fi
|
||||
done < <(collect_empty_dirs | sort -u)
|
||||
|
||||
mode="managed"
|
||||
[[ $ALL_EMPTY -eq 1 ]] && mode="all-empty"
|
||||
|
||||
if [[ $APPLY -eq 1 ]]; then
|
||||
echo "[mosaic-clean] complete: mode=$mode deleted_or_attempted=$count_deletable candidates=$count_candidates runtime=$RUNTIME"
|
||||
else
|
||||
echo "[mosaic-clean] dry-run: mode=$mode deletable=$count_deletable candidates=$count_candidates runtime=$RUNTIME"
|
||||
echo "[mosaic-clean] re-run with --apply to delete"
|
||||
fi
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -x "scripts/agent/critical.sh" ]]; then
|
||||
exec bash scripts/agent/critical.sh
|
||||
fi
|
||||
|
||||
echo "[mosaic] Missing scripts/agent/critical.sh in $(pwd)" >&2
|
||||
exit 1
|
||||
+639
@@ -0,0 +1,639 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
FAIL_ON_WARN=0
|
||||
VERBOSE=0
|
||||
FIX_MODE=0
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") [options]
|
||||
|
||||
Audit Mosaic runtime state and detect drift across agent runtimes.
|
||||
|
||||
Options:
|
||||
--fix Auto-fix: create missing dirs, wire skills into all harnesses
|
||||
--fail-on-warn Exit non-zero when warnings are found
|
||||
--verbose Print pass checks too
|
||||
-h, --help Show help
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--fix)
|
||||
FIX_MODE=1
|
||||
shift
|
||||
;;
|
||||
--fail-on-warn)
|
||||
FAIL_ON_WARN=1
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
fix_count=0
|
||||
fix() { fix_count=$((fix_count + 1)); echo "[FIX] $*"; }
|
||||
|
||||
warn_count=0
|
||||
warn() { warn_count=$((warn_count + 1)); echo "[WARN] $*"; }
|
||||
note() { echo "[NOTE] $*"; return 0; }
|
||||
|
||||
pass() {
|
||||
if [[ $VERBOSE -eq 1 ]]; then
|
||||
echo "[OK] $*"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
expect_dir() {
|
||||
local d="$1"
|
||||
if [[ ! -d "$d" ]]; then
|
||||
warn "Missing directory: $d"
|
||||
else
|
||||
pass "Directory present: $d"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_file() {
|
||||
local f="$1"
|
||||
if [[ ! -f "$f" ]]; then
|
||||
warn "Missing file: $f"
|
||||
else
|
||||
pass "File present: $f"
|
||||
fi
|
||||
}
|
||||
|
||||
check_runtime_file_copy() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
|
||||
[[ -f "$src" ]] || return 0
|
||||
|
||||
if [[ ! -e "$dst" ]]; then
|
||||
warn "Missing runtime file: $dst"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -L "$dst" ]]; then
|
||||
warn "Runtime file should not be symlinked: $dst"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! cmp -s "$src" "$dst"; then
|
||||
warn "Runtime file drift: $dst (does not match $src)"
|
||||
else
|
||||
pass "Runtime file synced: $dst"
|
||||
fi
|
||||
}
|
||||
|
||||
check_runtime_contract_file() {
|
||||
local dst="$1"
|
||||
local adapter_src="$2"
|
||||
local runtime_name="$3"
|
||||
|
||||
if [[ ! -e "$dst" ]]; then
|
||||
warn "Missing runtime file: $dst"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -L "$dst" ]]; then
|
||||
warn "Runtime file should not be symlinked: $dst"
|
||||
return
|
||||
fi
|
||||
|
||||
# Accept direct-adapter copy mode.
|
||||
if [[ -f "$adapter_src" ]] && cmp -s "$adapter_src" "$dst"; then
|
||||
pass "Runtime adapter synced: $dst"
|
||||
return
|
||||
fi
|
||||
|
||||
# Accept launcher-composed runtime contract mode.
|
||||
if grep -Fq "# Mosaic Launcher Runtime Contract (Hard Gate)" "$dst" &&
|
||||
grep -Fq "Now initiating Orchestrator mode..." "$dst" &&
|
||||
grep -Fq "Mosaic hard gates OVERRIDE runtime-default caution" "$dst" &&
|
||||
grep -Fq "# Runtime-Specific Contract" "$dst"; then
|
||||
pass "Runtime contract present: $dst ($runtime_name)"
|
||||
return
|
||||
fi
|
||||
|
||||
warn "Runtime file drift: $dst (not adapter copy and not composed runtime contract)"
|
||||
}
|
||||
|
||||
warn_if_symlink_tree_present() {
|
||||
local p="$1"
|
||||
[[ -e "$p" ]] || return 0
|
||||
|
||||
if [[ -L "$p" ]]; then
|
||||
warn "Legacy symlink path still present: $p"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -d "$p" ]]; then
|
||||
symlink_count=$(find "$p" -type l 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [[ "$symlink_count" != "0" ]]; then
|
||||
warn "Legacy symlink entries still present under $p: $symlink_count"
|
||||
else
|
||||
pass "No symlinks under legacy path: $p"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
echo "[mosaic-doctor] Mosaic home: $MOSAIC_HOME"
|
||||
|
||||
# Compare the framework tools that this CLI/package ships with the deployed
|
||||
# ~/.config copy that direct wrappers and systemd units actually execute. Doctor
|
||||
# is the right boundary: observational, operator-invoked, and already designed
|
||||
# to report drift without mutating live tooling or restarting active seats.
|
||||
framework_drift_checker="$(cd -- "$(dirname -- "$0")/../quality/scripts" && pwd)/framework-drift-check.py"
|
||||
if [[ -f "$framework_drift_checker" ]]; then
|
||||
echo "[mosaic-doctor] Checking installed framework-tool drift..."
|
||||
drift_timeout="${MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC:-15}"
|
||||
if ! [[ "$drift_timeout" =~ ^[1-9][0-9]*$ ]]; then
|
||||
warn "Invalid MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC='$drift_timeout' (expected positive integer); using 15s"
|
||||
drift_timeout=15
|
||||
fi
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
set +e
|
||||
timeout -s TERM -k 2 "${drift_timeout}s" \
|
||||
python3 "$framework_drift_checker" --installed-root "$MOSAIC_HOME/tools"
|
||||
drift_rc=$?
|
||||
set -e
|
||||
if [[ "$drift_rc" -eq 0 ]]; then
|
||||
pass "Installed framework tools match shipped source"
|
||||
elif [[ "$drift_rc" -eq 124 || "$drift_rc" -eq 137 || "$drift_rc" -eq 143 ]]; then
|
||||
warn "CANNOT_ASSERT framework drift checker timed out after ${drift_timeout}s; continuing remaining doctor checks"
|
||||
else
|
||||
warn "Installed framework-tool drift detected (checker exit $drift_rc; no files changed)"
|
||||
fi
|
||||
else
|
||||
warn "CANNOT_ASSERT timeout utility unavailable; refusing unbounded framework drift check and continuing remaining doctor checks"
|
||||
fi
|
||||
else
|
||||
warn "Framework drift checker is absent from the shipped tools tree"
|
||||
fi
|
||||
|
||||
# Canonical Mosaic checks
|
||||
expect_file "$MOSAIC_HOME/STANDARDS.md"
|
||||
expect_file "$MOSAIC_HOME/USER.md"
|
||||
expect_file "$MOSAIC_HOME/TOOLS.md"
|
||||
expect_dir "$MOSAIC_HOME/guides"
|
||||
expect_dir "$MOSAIC_HOME/tools"
|
||||
expect_dir "$MOSAIC_HOME/tools/quality"
|
||||
expect_dir "$MOSAIC_HOME/tools/orchestrator-matrix"
|
||||
expect_dir "$MOSAIC_HOME/profiles"
|
||||
expect_dir "$MOSAIC_HOME/templates/agent"
|
||||
expect_dir "$MOSAIC_HOME/skills"
|
||||
expect_dir "$MOSAIC_HOME/skills-local"
|
||||
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets"
|
||||
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-ensure-sequential-thinking"
|
||||
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-sync-skills"
|
||||
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-projects"
|
||||
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-quality-apply"
|
||||
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-quality-verify"
|
||||
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-run"
|
||||
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-sync-tasks"
|
||||
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-drain"
|
||||
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-matrix-publish"
|
||||
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-matrix-consume"
|
||||
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-matrix-cycle"
|
||||
expect_file "$MOSAIC_HOME/tools/git/ci-queue-wait.sh"
|
||||
expect_file "$MOSAIC_HOME/tools/git/pr-ci-wait.sh"
|
||||
expect_file "$MOSAIC_HOME/tools/orchestrator-matrix/transport/matrix_transport.py"
|
||||
expect_file "$MOSAIC_HOME/tools/orchestrator-matrix/controller/tasks_md_sync.py"
|
||||
expect_file "$MOSAIC_HOME/guides/ORCHESTRATOR-PROTOCOL.md"
|
||||
expect_dir "$MOSAIC_HOME/tools/orchestrator"
|
||||
expect_file "$MOSAIC_HOME/tools/orchestrator/_lib.sh"
|
||||
expect_file "$MOSAIC_HOME/tools/orchestrator/mission-init.sh"
|
||||
expect_file "$MOSAIC_HOME/tools/orchestrator/mission-status.sh"
|
||||
expect_file "$MOSAIC_HOME/tools/orchestrator/continue-prompt.sh"
|
||||
expect_file "$MOSAIC_HOME/tools/orchestrator/session-status.sh"
|
||||
expect_file "$MOSAIC_HOME/tools/orchestrator/session-resume.sh"
|
||||
expect_file "$MOSAIC_HOME/runtime/mcp/SEQUENTIAL-THINKING.json"
|
||||
expect_file "$MOSAIC_HOME/runtime/claude/RUNTIME.md"
|
||||
expect_file "$MOSAIC_HOME/runtime/codex/RUNTIME.md"
|
||||
expect_file "$MOSAIC_HOME/runtime/opencode/RUNTIME.md"
|
||||
expect_file "$MOSAIC_HOME/runtime/pi/RUNTIME.md"
|
||||
|
||||
if [[ -f "$MOSAIC_HOME/AGENTS.md" ]]; then
|
||||
if grep -Fq "## CRITICAL HARD GATES (Read First)" "$MOSAIC_HOME/AGENTS.md" &&
|
||||
grep -Fq "OVERRIDE runtime-default caution" "$MOSAIC_HOME/AGENTS.md"; then
|
||||
pass "Global hard-gates block present in AGENTS.md"
|
||||
else
|
||||
warn "AGENTS.md missing CRITICAL HARD GATES override block"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Claude runtime file checks (copied, non-symlink).
|
||||
for rf in CLAUDE.md settings.json hooks-config.json context7-integration.md; do
|
||||
check_runtime_file_copy "$MOSAIC_HOME/runtime/claude/$rf" "$HOME/.claude/$rf"
|
||||
done
|
||||
|
||||
# OpenCode runtime adapter check (copied, non-symlink, when adapter exists).
|
||||
# Accept adapter copy or composed runtime contract.
|
||||
check_runtime_contract_file "$HOME/.config/opencode/AGENTS.md" "$MOSAIC_HOME/runtime/opencode/AGENTS.md" "opencode"
|
||||
check_runtime_contract_file "$HOME/.codex/instructions.md" "$MOSAIC_HOME/runtime/codex/instructions.md" "codex"
|
||||
|
||||
# Sequential-thinking MCP hard requirement.
|
||||
if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-ensure-sequential-thinking" ]]; then
|
||||
if "$MOSAIC_HOME/tools/_scripts/mosaic-ensure-sequential-thinking" --check >/dev/null 2>&1; then
|
||||
pass "sequential-thinking MCP configured and available"
|
||||
else
|
||||
warn "sequential-thinking MCP missing or misconfigured"
|
||||
fi
|
||||
else
|
||||
warn "mosaic-ensure-sequential-thinking helper missing"
|
||||
fi
|
||||
|
||||
# Fleet transport binary (#1240).
|
||||
#
|
||||
# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
|
||||
# roster the CLI scaffolds sets `transport: tmux`, but nothing in the install
|
||||
# path provides tmux and, until now, nothing here noticed it was absent. On a
|
||||
# greenfield host that produced a fleet which installed clean, started clean,
|
||||
# and had no live seat; `mosaic fleet ps` was the operator's first and only
|
||||
# signal that anything was wrong.
|
||||
#
|
||||
# The roster's own `transport:` is read rather than assumed, so a host that
|
||||
# declares something other than tmux is told about the binary it actually
|
||||
# needs. Absent a roster the check still runs — `mosaic fleet init` will
|
||||
# scaffold a tmux fleet on this host, and finding out beforehand is the point.
|
||||
#
|
||||
# `tools/install.sh` carries a deliberately parallel check at the end of its
|
||||
# summary. The two are separate because the installer must be able to say this
|
||||
# before the framework's own scripts are guaranteed to be on disk; keep their
|
||||
# wording in step.
|
||||
fleet_declared_transport() {
|
||||
local roster="$MOSAIC_HOME/fleet/roster.yaml"
|
||||
local declared=""
|
||||
|
||||
if [[ -f "$roster" ]]; then
|
||||
declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 |
|
||||
tr -d '"'\''' | tr -d '\r' | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
printf '%s\n' "${declared:-tmux}"
|
||||
}
|
||||
|
||||
# Brain-home fleet-state resolution (#1298; canon STRUCTURE-CANON §2).
|
||||
#
|
||||
# Seat launch envs, roles.local overrides, and profile working copies resolve
|
||||
# from the brain home when one is active; roster, baseline roles, run/, and
|
||||
# services stay under MOSAIC_HOME. This check surfaces which tree fleet state
|
||||
# resolves from and the drift a launch would otherwise hit at runtime:
|
||||
#
|
||||
# - a stale MOSAIC_BRAIN_HOME pointing at a directory with no fleet/agents is a
|
||||
# misconfiguration the resolver honors (explicit wins) — warn, don't pass;
|
||||
# - a symlinked brain or agents dir defeats the managed-directory boundary;
|
||||
# - a group/world-readable agents dir violates the 0700 projection boundary;
|
||||
# - env files left in the config-home tree while a brain is active are split
|
||||
# state — the write path rejects NEW split writes, but nothing would ever
|
||||
# tell the operator the old files are stranded.
|
||||
resolve_brain_home() {
|
||||
local explicit="${MOSAIC_BRAIN_HOME:-}"
|
||||
if [[ -n "$(printf '%s' "$explicit" | tr -d '[:space:]')" ]]; then
|
||||
printf '%s' "$explicit"
|
||||
return
|
||||
fi
|
||||
if [[ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" == "$HOME/.config/mosaic" \
|
||||
&& -d "$HOME/.mosaic/fleet/agents" ]]; then
|
||||
printf '%s' "$HOME/.mosaic"
|
||||
return
|
||||
fi
|
||||
printf '%s' "$MOSAIC_HOME"
|
||||
}
|
||||
|
||||
check_brain_home() {
|
||||
local brain agents mode
|
||||
brain="$(resolve_brain_home)"
|
||||
|
||||
if [[ "$brain" == "$MOSAIC_HOME" ]]; then
|
||||
# Implicit-path greenfield case (#1288 comment 23133, fred's trace): nothing
|
||||
# in product code creates ~/.mosaic/fleet/agents — the first fleet write
|
||||
# resolves legacy (generated-env-boundary resolves before creating) and
|
||||
# then manufactures the evidence that keeps the host legacy. On a host with
|
||||
# ~/.mosaic but no fleet/agents, the three operator checks all agree and all
|
||||
# point the wrong way; this doctor is the only one that can disagree, so it
|
||||
# must say it — as a note, not a warn: nothing is broken yet.
|
||||
if [[ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" == "$HOME/.config/mosaic" \
|
||||
&& -d "$HOME/.mosaic" && ! -d "$HOME/.mosaic/fleet/agents" ]]; then
|
||||
note "Fleet state home: $MOSAIC_HOME (legacy). NOTE: ~/.mosaic exists but carries no fleet/agents — the first 'mosaic fleet regen' on this host locks in the legacy tree. Create ~/.mosaic/fleet/agents first to adopt the brain."
|
||||
return
|
||||
fi
|
||||
pass "Fleet state home: $MOSAIC_HOME (legacy single-tree; no brain adopted)"
|
||||
return
|
||||
fi
|
||||
|
||||
agents="$brain/fleet/agents"
|
||||
if [[ ! -d "$agents" ]]; then
|
||||
warn "Brain home '$brain' has no fleet/agents — seat envs will not resolve from it. Point MOSAIC_BRAIN_HOME at a brain carrying fleet/agents, or unset it."
|
||||
return
|
||||
fi
|
||||
if [[ -L "$brain" || -L "$agents" ]]; then
|
||||
warn "Brain fleet-state path resolves through a symlink ($brain) — the managed-directory boundary requires regular directories."
|
||||
return
|
||||
fi
|
||||
|
||||
mode="$(stat -c '%a' -- "$agents" 2>/dev/null)" || mode=""
|
||||
if [[ -n "$mode" ]] && (( (8#$mode & 8#077) != 0 )); then
|
||||
warn "Brain agents dir '$agents' is group/world-accessible (mode $mode) — the projection boundary requires 0700."
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -d "$MOSAIC_HOME/fleet/agents" ]] \
|
||||
&& ls "$MOSAIC_HOME/fleet/agents/"*.env* >/dev/null 2>&1; then
|
||||
warn "Fleet env files exist in BOTH trees — brain '$brain' is active but '$MOSAIC_HOME/fleet/agents' still carries env files (split state). Migrate them (mosaic fleet regen) and remove the config-home copies."
|
||||
return
|
||||
fi
|
||||
|
||||
pass "Fleet state home: $brain (brain active); roster + templates: $MOSAIC_HOME"
|
||||
}
|
||||
|
||||
check_fleet_transport() {
|
||||
local transport
|
||||
transport="$(fleet_declared_transport)"
|
||||
|
||||
if command -v "$transport" >/dev/null 2>&1; then
|
||||
pass "Fleet transport available: $transport"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -f "$MOSAIC_HOME/fleet/roster.yaml" ]]; then
|
||||
warn "Fleet transport '$transport' is not installed — this host has a roster and no seat can launch. Install it (e.g. sudo apt-get install -y $transport), then 'mosaic fleet start'."
|
||||
else
|
||||
warn "Fleet transport '$transport' is not installed — 'mosaic fleet' cannot run seats here. Install it (e.g. sudo apt-get install -y $transport) before 'mosaic fleet init'."
|
||||
fi
|
||||
}
|
||||
|
||||
check_structure_anchor_provisioning() {
|
||||
# T51 WP0b (spec §1.2a + PHASE2-MAP F7): audit the two declaration anchors.
|
||||
# Doctor runs from operator shells and CI where the launcher exports do not
|
||||
# exist, so this is an AUDIT ONLY — it never exports, writes, or fabricates
|
||||
# values for consumption. Four states (charter):
|
||||
# both present+nonempty PASS (values reported as paths only)
|
||||
# one missing/empty WARN naming the var + the launcher as authority
|
||||
# neither present INFORMATIONAL launcher-equivalent derivation,
|
||||
# explicitly non-authoritative, + launcher warning;
|
||||
# never an error by design (F7(b))
|
||||
# Severity follows the doctor's existing conventions: pass/note are quiet
|
||||
# (note unless --verbose), warn counts toward --fail-on-warn.
|
||||
local host_root="${MOSAIC_HOST_ROOT:-}" brain_home="${MOSAIC_BRAIN_HOME:-}"
|
||||
# T51P2WP0BRW B1: presence is tracked SEPARATELY from value — `${VAR:-}`
|
||||
# collapses exported-empty into genuinely-unset, which mis-filed both-empty
|
||||
# and the mixed empty/unset states as informational. Only BOTH-genuinely-
|
||||
# absent may be informational (charter state 3); any present-but-empty or
|
||||
# single-present state warns.
|
||||
local host_set=0 brain_set=0
|
||||
[[ -v MOSAIC_HOST_ROOT ]] && host_set=1
|
||||
[[ -v MOSAIC_BRAIN_HOME ]] && brain_set=1
|
||||
if [[ "$host_set" -eq 1 && "$brain_set" -eq 1 && -n "$host_root" && -n "$brain_home" ]]; then
|
||||
pass "Structure anchors provisioned: MOSAIC_HOST_ROOT=$host_root MOSAIC_BRAIN_HOME=$brain_home (paths reported only; not expanded, not consumed)"
|
||||
return
|
||||
fi
|
||||
if [[ "$host_set" -eq 0 && "$brain_set" -eq 0 ]]; then
|
||||
note "Structure anchors not provisioned in this environment. Launcher-equivalent derivation (INFORMATIONAL, NON-AUTHORITATIVE — seats receive the authoritative values from the launchers): MOSAIC_HOST_ROOT would default to the operator home; MOSAIC_BRAIN_HOME would default to the brain tree resolved at launch. Doctor does not guess values for consumption; it audits provisioning."
|
||||
note "Provision both anchors via the seat launchers (launch-seat.sh / launch-seat-claude.sh export them; see T51 spec §1.2a)."
|
||||
return
|
||||
fi
|
||||
# At least one variable is present (possibly empty), or exactly one exists:
|
||||
# every missing/empty anchor gets its own loud WARN naming the launchers.
|
||||
if [[ "$host_set" -eq 0 ]]; then
|
||||
warn "MOSAIC_HOST_ROOT is not set in this environment while MOSAIC_BRAIN_HOME is — declaration consumers fail closed without it (spec §1.2a). The seat launchers are the authoritative source."
|
||||
elif [[ -z "$host_root" ]]; then
|
||||
warn "MOSAIC_HOST_ROOT is present but EMPTY in this environment — declaration consumers fail closed without a usable value (spec §1.2a). The seat launchers are the authoritative source."
|
||||
fi
|
||||
if [[ "$brain_set" -eq 0 ]]; then
|
||||
warn "MOSAIC_BRAIN_HOME is not set in this environment while MOSAIC_HOST_ROOT is — the projects/ mirror and brain declaration resolve from it (spec §1.2a). The seat launchers are the authoritative source."
|
||||
elif [[ -z "$brain_home" ]]; then
|
||||
warn "MOSAIC_BRAIN_HOME is present but EMPTY in this environment — the projects/ mirror and brain declaration resolve from it (spec §1.2a). The seat launchers are the authoritative source."
|
||||
fi
|
||||
}
|
||||
|
||||
check_fleet_transport
|
||||
|
||||
check_structure_anchor_provisioning
|
||||
|
||||
check_brain_home
|
||||
|
||||
# Legacy migration surfaces should no longer contain symlink trees.
|
||||
legacy_paths=(
|
||||
"$HOME/.claude/agent-guides"
|
||||
"$HOME/.claude/scripts/git"
|
||||
"$HOME/.claude/scripts/codex"
|
||||
"$HOME/.claude/scripts/bootstrap"
|
||||
"$HOME/.claude/scripts/cicd"
|
||||
"$HOME/.claude/scripts/portainer"
|
||||
"$HOME/.claude/templates"
|
||||
"$HOME/.claude/presets/domains"
|
||||
"$HOME/.claude/presets/tech-stacks"
|
||||
"$HOME/.claude/presets/workflows"
|
||||
)
|
||||
for p in "${legacy_paths[@]}"; do
|
||||
warn_if_symlink_tree_present "$p"
|
||||
done
|
||||
|
||||
# Skills runtime checks (still symlinked into runtime-specific skills dirs).
|
||||
for runtime_skills in "$HOME/.claude/skills" "$HOME/.codex/skills" "$HOME/.config/opencode/skills" "$HOME/.pi/agent/skills"; do
|
||||
[[ -d "$runtime_skills" ]] || continue
|
||||
|
||||
while IFS= read -r -d '' skill; do
|
||||
name="$(basename "$skill")"
|
||||
[[ "$name" == .* ]] && continue
|
||||
target="$runtime_skills/$name"
|
||||
|
||||
if [[ ! -e "$target" ]]; then
|
||||
warn "Missing skill link: $target"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ ! -L "$target" ]]; then
|
||||
warn "Non-symlink skill entry: $target"
|
||||
continue
|
||||
fi
|
||||
|
||||
target_real="$(readlink -f "$target" 2>/dev/null || true)"
|
||||
skill_real="$(readlink -f "$skill" 2>/dev/null || true)"
|
||||
if [[ -z "$target_real" || -z "$skill_real" || "$target_real" != "$skill_real" ]]; then
|
||||
warn "Drifted skill link: $target (expected -> $skill)"
|
||||
else
|
||||
pass "Linked skill: $target"
|
||||
fi
|
||||
done < <(find "$MOSAIC_HOME/skills" "$MOSAIC_HOME/skills-local" -mindepth 1 -maxdepth 1 -type d -print0)
|
||||
done
|
||||
|
||||
# Broken links only in managed runtime skill dirs.
|
||||
link_roots=(
|
||||
"$HOME/.claude/skills"
|
||||
"$HOME/.codex/skills"
|
||||
"$HOME/.config/opencode/skills"
|
||||
"$HOME/.pi/agent/skills"
|
||||
)
|
||||
existing_link_roots=()
|
||||
for d in "${link_roots[@]}"; do
|
||||
[[ -e "$d" ]] && existing_link_roots+=("$d")
|
||||
done
|
||||
|
||||
broken_links=0
|
||||
if [[ ${#existing_link_roots[@]} -gt 0 ]]; then
|
||||
broken_links=$(find "${existing_link_roots[@]}" -xtype l 2>/dev/null | wc -l | tr -d ' ')
|
||||
fi
|
||||
if [[ "$broken_links" != "0" ]]; then
|
||||
warn "Broken skill symlinks detected: $broken_links"
|
||||
fi
|
||||
|
||||
# Pi agent skills directory check.
|
||||
if [[ ! -d "$HOME/.pi/agent/skills" ]]; then
|
||||
warn "Pi skills directory missing: $HOME/.pi/agent/skills"
|
||||
else
|
||||
pass "Pi skills directory present: $HOME/.pi/agent/skills"
|
||||
fi
|
||||
|
||||
# Pi settings.json — check skills path is configured.
|
||||
pi_settings="$HOME/.pi/agent/settings.json"
|
||||
if [[ -f "$pi_settings" ]]; then
|
||||
if grep -q 'skills' "$pi_settings" 2>/dev/null; then
|
||||
pass "Pi settings.json has skills configuration"
|
||||
else
|
||||
warn "Pi settings.json missing skills array — Mosaic skills may not load"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Mosaic-specific skills presence check.
|
||||
mosaic_skills=(mosaic-board mosaic-forge mosaic-prdy mosaic-macp mosaic-standards mosaic-prd mosaic-setup-cicd)
|
||||
for skill_name in "${mosaic_skills[@]}"; do
|
||||
if [[ -d "$MOSAIC_HOME/skills/$skill_name" ]] || [[ -L "$MOSAIC_HOME/skills/$skill_name" ]]; then
|
||||
pass "Mosaic skill present: $skill_name"
|
||||
elif [[ -d "$MOSAIC_HOME/skills-local/$skill_name" ]]; then
|
||||
pass "Mosaic skill present (local): $skill_name"
|
||||
else
|
||||
warn "Missing Mosaic skill: $skill_name"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── --fix mode: auto-wire skills into all harness directories ──────────────
|
||||
if [[ $FIX_MODE -eq 1 ]]; then
|
||||
echo ""
|
||||
echo "[mosaic-doctor] Running auto-fix..."
|
||||
|
||||
# 1. Ensure all harness skill directories exist
|
||||
harness_skill_dirs=(
|
||||
"$HOME/.claude/skills"
|
||||
"$HOME/.codex/skills"
|
||||
"$HOME/.config/opencode/skills"
|
||||
"$HOME/.pi/agent/skills"
|
||||
)
|
||||
|
||||
for hdir in "${harness_skill_dirs[@]}"; do
|
||||
if [[ ! -d "$hdir" ]]; then
|
||||
mkdir -p "$hdir"
|
||||
fix "Created missing directory: $hdir"
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. Wire all Mosaic skills (canonical + local) into every harness
|
||||
skill_sources=("$MOSAIC_HOME/skills" "$MOSAIC_HOME/skills-local")
|
||||
|
||||
for hdir in "${harness_skill_dirs[@]}"; do
|
||||
# Skip if target resolves to canonical dir (avoid self-link)
|
||||
hdir_real="$(readlink -f "$hdir" 2>/dev/null || true)"
|
||||
canonical_real="$(readlink -f "$MOSAIC_HOME/skills" 2>/dev/null || true)"
|
||||
if [[ -n "$hdir_real" && -n "$canonical_real" && "$hdir_real" == "$canonical_real" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
for src_dir in "${skill_sources[@]}"; do
|
||||
[[ -d "$src_dir" ]] || continue
|
||||
|
||||
while IFS= read -r -d '' skill_path; do
|
||||
skill_name="$(basename "$skill_path")"
|
||||
[[ "$skill_name" == .* ]] && continue
|
||||
|
||||
link_path="$hdir/$skill_name"
|
||||
|
||||
if [[ -L "$link_path" ]]; then
|
||||
# Repoint if target differs
|
||||
current_target="$(readlink -f "$link_path" 2>/dev/null || true)"
|
||||
expected_target="$(readlink -f "$skill_path" 2>/dev/null || true)"
|
||||
if [[ "$current_target" != "$expected_target" ]]; then
|
||||
ln -sfn "$skill_path" "$link_path"
|
||||
fix "Repointed skill link: $link_path -> $skill_path"
|
||||
fi
|
||||
elif [[ -e "$link_path" ]]; then
|
||||
# Non-symlink entry — preserve runtime-specific override
|
||||
continue
|
||||
else
|
||||
ln -s "$skill_path" "$link_path"
|
||||
fix "Linked skill: $link_path -> $skill_path"
|
||||
fi
|
||||
done < <(find "$src_dir" -mindepth 1 -maxdepth 1 -type d -print0; find "$src_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
||||
done
|
||||
|
||||
# Prune broken symlinks in this harness dir
|
||||
while IFS= read -r -d '' broken_link; do
|
||||
rm -f "$broken_link"
|
||||
fix "Removed broken link: $broken_link"
|
||||
done < <(find "$hdir" -mindepth 1 -maxdepth 1 -xtype l -print0 2>/dev/null)
|
||||
done
|
||||
|
||||
# 3. Ensure Pi settings.json includes Mosaic skills path
|
||||
pi_settings_dir="$HOME/.pi/agent"
|
||||
pi_settings_file="$pi_settings_dir/settings.json"
|
||||
mkdir -p "$pi_settings_dir"
|
||||
|
||||
if [[ ! -f "$pi_settings_file" ]]; then
|
||||
echo '{}' > "$pi_settings_file"
|
||||
fix "Created Pi settings.json: $pi_settings_file"
|
||||
fi
|
||||
|
||||
# Add skills paths if not already present
|
||||
mosaic_skills_path="$MOSAIC_HOME/skills"
|
||||
mosaic_local_path="$MOSAIC_HOME/skills-local"
|
||||
if ! grep -q "$mosaic_skills_path" "$pi_settings_file" 2>/dev/null; then
|
||||
# Use a simple approach: read, patch, write
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
with open('$pi_settings_file', 'r') as f:
|
||||
data = json.load(f)
|
||||
skills = data.get('skills', [])
|
||||
if not isinstance(skills, list):
|
||||
skills = []
|
||||
for p in ['$mosaic_skills_path', '$mosaic_local_path']:
|
||||
if p not in skills:
|
||||
skills.append(p)
|
||||
data['skills'] = skills
|
||||
with open('$pi_settings_file', 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write('\\n')
|
||||
" 2>/dev/null && fix "Added Mosaic skills paths to Pi settings.json"
|
||||
else
|
||||
warn "python3 not available — cannot patch Pi settings.json. Add manually: skills: [\"$mosaic_skills_path\", \"$mosaic_local_path\"]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Run link-runtime-assets if available
|
||||
if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" ]]; then
|
||||
"$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" >/dev/null 2>&1 && fix "Re-ran mosaic-link-runtime-assets"
|
||||
fi
|
||||
|
||||
echo "[mosaic-doctor] fixes=$fix_count"
|
||||
fi
|
||||
|
||||
echo "[mosaic-doctor] warnings=$warn_count"
|
||||
if [[ $FAIL_ON_WARN -eq 1 && $warn_count -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,283 @@
|
||||
# mosaic-doctor.ps1
|
||||
# Audits Mosaic runtime state and detects drift across agent runtimes.
|
||||
# PowerShell equivalent of mosaic-doctor (bash).
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
param(
|
||||
[switch]$FailOnWarn,
|
||||
[switch]$Verbose,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$MosaicHome = if ($env:MOSAIC_HOME) { $env:MOSAIC_HOME } else { Join-Path $env:USERPROFILE ".config\mosaic" }
|
||||
|
||||
if ($Help) {
|
||||
Write-Host @"
|
||||
Usage: mosaic-doctor.ps1 [-FailOnWarn] [-Verbose] [-Help]
|
||||
|
||||
Audit Mosaic runtime state and detect drift across agent runtimes.
|
||||
"@
|
||||
exit 0
|
||||
}
|
||||
|
||||
$script:warnCount = 0
|
||||
|
||||
function Warn {
|
||||
param([string]$Message)
|
||||
$script:warnCount++
|
||||
Write-Host "[WARN] $Message" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
function Pass {
|
||||
param([string]$Message)
|
||||
if ($Verbose) { Write-Host "[OK] $Message" -ForegroundColor Green }
|
||||
}
|
||||
|
||||
function Expect-Dir {
|
||||
param([string]$Path)
|
||||
if (-not (Test-Path $Path -PathType Container)) { Warn "Missing directory: $Path" }
|
||||
else { Pass "Directory present: $Path" }
|
||||
}
|
||||
|
||||
function Expect-File {
|
||||
param([string]$Path)
|
||||
if (-not (Test-Path $Path -PathType Leaf)) { Warn "Missing file: $Path" }
|
||||
else { Pass "File present: $Path" }
|
||||
}
|
||||
|
||||
function Check-RuntimeFileCopy {
|
||||
param([string]$Src, [string]$Dst)
|
||||
|
||||
if (-not (Test-Path $Src)) { return }
|
||||
|
||||
if (-not (Test-Path $Dst)) {
|
||||
Warn "Missing runtime file: $Dst"
|
||||
return
|
||||
}
|
||||
|
||||
$item = Get-Item $Dst -Force -ErrorAction SilentlyContinue
|
||||
if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
||||
Warn "Runtime file should not be symlinked: $Dst"
|
||||
return
|
||||
}
|
||||
|
||||
$srcHash = (Get-FileHash $Src -Algorithm SHA256).Hash
|
||||
$dstHash = (Get-FileHash $Dst -Algorithm SHA256).Hash
|
||||
if ($srcHash -ne $dstHash) {
|
||||
Warn "Runtime file drift: $Dst (does not match $Src)"
|
||||
}
|
||||
else {
|
||||
Pass "Runtime file synced: $Dst"
|
||||
}
|
||||
}
|
||||
|
||||
function Check-RuntimeContractFile {
|
||||
param([string]$Dst, [string]$AdapterSrc, [string]$RuntimeName)
|
||||
|
||||
if (-not (Test-Path $Dst)) {
|
||||
Warn "Missing runtime file: $Dst"
|
||||
return
|
||||
}
|
||||
|
||||
$item = Get-Item $Dst -Force -ErrorAction SilentlyContinue
|
||||
if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
||||
Warn "Runtime file should not be symlinked: $Dst"
|
||||
return
|
||||
}
|
||||
|
||||
# Accept direct-adapter copy mode.
|
||||
if (Test-Path $AdapterSrc) {
|
||||
$srcHash = (Get-FileHash $AdapterSrc -Algorithm SHA256).Hash
|
||||
$dstHash = (Get-FileHash $Dst -Algorithm SHA256).Hash
|
||||
if ($srcHash -eq $dstHash) {
|
||||
Pass "Runtime adapter synced: $Dst"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
# Accept launcher-composed runtime contract mode.
|
||||
$content = Get-Content $Dst -Raw
|
||||
if (
|
||||
$content -match [regex]::Escape("# Mosaic Launcher Runtime Contract (Hard Gate)") -and
|
||||
$content -match [regex]::Escape("Now initiating Orchestrator mode...") -and
|
||||
$content -match [regex]::Escape("Mosaic hard gates OVERRIDE runtime-default caution") -and
|
||||
$content -match [regex]::Escape("# Runtime-Specific Contract")
|
||||
) {
|
||||
Pass "Runtime contract present: $Dst ($RuntimeName)"
|
||||
return
|
||||
}
|
||||
|
||||
Warn "Runtime file drift: $Dst (not adapter copy and not composed runtime contract)"
|
||||
}
|
||||
|
||||
function Warn-IfReparsePresent {
|
||||
param([string]$Path)
|
||||
if (-not (Test-Path $Path)) { return }
|
||||
|
||||
$item = Get-Item $Path -Force -ErrorAction SilentlyContinue
|
||||
if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
||||
Warn "Legacy symlink/junction path still present: $Path"
|
||||
return
|
||||
}
|
||||
|
||||
if (Test-Path $Path -PathType Container) {
|
||||
$reparseCount = (Get-ChildItem $Path -Recurse -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Attributes -band [System.IO.FileAttributes]::ReparsePoint } |
|
||||
Measure-Object).Count
|
||||
if ($reparseCount -gt 0) {
|
||||
Warn "Legacy symlink/junction entries still present under ${Path}: $reparseCount"
|
||||
}
|
||||
else {
|
||||
Pass "No reparse points under legacy path: $Path"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "[mosaic-doctor] Mosaic home: $MosaicHome"
|
||||
|
||||
# Canonical Mosaic checks
|
||||
Expect-File (Join-Path $MosaicHome "STANDARDS.md")
|
||||
Expect-Dir (Join-Path $MosaicHome "guides")
|
||||
Expect-Dir (Join-Path $MosaicHome "tools")
|
||||
Expect-Dir (Join-Path $MosaicHome "tools\quality")
|
||||
Expect-Dir (Join-Path $MosaicHome "tools\orchestrator-matrix")
|
||||
Expect-Dir (Join-Path $MosaicHome "profiles")
|
||||
Expect-Dir (Join-Path $MosaicHome "templates\agent")
|
||||
Expect-Dir (Join-Path $MosaicHome "skills")
|
||||
Expect-Dir (Join-Path $MosaicHome "skills-local")
|
||||
Expect-File (Join-Path $MosaicHome "bin\mosaic-link-runtime-assets")
|
||||
Expect-File (Join-Path $MosaicHome "bin\mosaic-ensure-sequential-thinking.ps1")
|
||||
Expect-File (Join-Path $MosaicHome "bin\mosaic-sync-skills")
|
||||
Expect-File (Join-Path $MosaicHome "bin\mosaic-projects")
|
||||
Expect-File (Join-Path $MosaicHome "bin\mosaic-quality-apply")
|
||||
Expect-File (Join-Path $MosaicHome "bin\mosaic-quality-verify")
|
||||
Expect-File (Join-Path $MosaicHome "bin\mosaic-orchestrator-run")
|
||||
Expect-File (Join-Path $MosaicHome "bin\mosaic-orchestrator-sync-tasks")
|
||||
Expect-File (Join-Path $MosaicHome "bin\mosaic-orchestrator-drain")
|
||||
Expect-File (Join-Path $MosaicHome "bin\mosaic-orchestrator-matrix-publish")
|
||||
Expect-File (Join-Path $MosaicHome "bin\mosaic-orchestrator-matrix-consume")
|
||||
Expect-File (Join-Path $MosaicHome "bin\mosaic-orchestrator-matrix-cycle")
|
||||
Expect-File (Join-Path $MosaicHome "tools\git\ci-queue-wait.ps1")
|
||||
Expect-File (Join-Path $MosaicHome "tools\git\ci-queue-wait.sh")
|
||||
Expect-File (Join-Path $MosaicHome "tools\git\pr-ci-wait.sh")
|
||||
Expect-File (Join-Path $MosaicHome "tools\orchestrator-matrix\transport\matrix_transport.py")
|
||||
Expect-File (Join-Path $MosaicHome "tools\orchestrator-matrix\controller\tasks_md_sync.py")
|
||||
Expect-File (Join-Path $MosaicHome "runtime\mcp\SEQUENTIAL-THINKING.json")
|
||||
Expect-File (Join-Path $MosaicHome "runtime\claude\RUNTIME.md")
|
||||
Expect-File (Join-Path $MosaicHome "runtime\codex\RUNTIME.md")
|
||||
Expect-File (Join-Path $MosaicHome "runtime\opencode\RUNTIME.md")
|
||||
|
||||
$agentsMd = Join-Path $MosaicHome "AGENTS.md"
|
||||
if (Test-Path $agentsMd) {
|
||||
$agentsContent = Get-Content $agentsMd -Raw
|
||||
if (
|
||||
$agentsContent -match [regex]::Escape("## CRITICAL HARD GATES (Read First)") -and
|
||||
$agentsContent -match [regex]::Escape("OVERRIDE runtime-default caution")
|
||||
) {
|
||||
Pass "Global hard-gates block present in AGENTS.md"
|
||||
}
|
||||
else {
|
||||
Warn "AGENTS.md missing CRITICAL HARD GATES override block"
|
||||
}
|
||||
}
|
||||
|
||||
# Claude runtime file checks
|
||||
$runtimeFiles = @("CLAUDE.md", "settings.json", "hooks-config.json", "context7-integration.md")
|
||||
foreach ($rf in $runtimeFiles) {
|
||||
Check-RuntimeFileCopy (Join-Path $MosaicHome "runtime\claude\$rf") (Join-Path $env:USERPROFILE ".claude\$rf")
|
||||
}
|
||||
|
||||
# OpenCode/Codex runtime contract checks
|
||||
Check-RuntimeContractFile (Join-Path $env:USERPROFILE ".config\opencode\AGENTS.md") (Join-Path $MosaicHome "runtime\opencode\AGENTS.md") "opencode"
|
||||
Check-RuntimeContractFile (Join-Path $env:USERPROFILE ".codex\instructions.md") (Join-Path $MosaicHome "runtime\codex\instructions.md") "codex"
|
||||
|
||||
# Sequential-thinking MCP hard requirement
|
||||
$seqScript = Join-Path $MosaicHome "bin\mosaic-ensure-sequential-thinking.ps1"
|
||||
if (Test-Path $seqScript) {
|
||||
try {
|
||||
& $seqScript -Check *>$null
|
||||
Pass "sequential-thinking MCP configured and available"
|
||||
}
|
||||
catch {
|
||||
Warn "sequential-thinking MCP missing or misconfigured"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Warn "mosaic-ensure-sequential-thinking helper missing"
|
||||
}
|
||||
|
||||
# Legacy migration surfaces
|
||||
$legacyPaths = @(
|
||||
(Join-Path $env:USERPROFILE ".claude\agent-guides"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\git"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\codex"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\bootstrap"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\cicd"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\portainer"),
|
||||
(Join-Path $env:USERPROFILE ".claude\templates"),
|
||||
(Join-Path $env:USERPROFILE ".claude\presets\domains"),
|
||||
(Join-Path $env:USERPROFILE ".claude\presets\tech-stacks"),
|
||||
(Join-Path $env:USERPROFILE ".claude\presets\workflows")
|
||||
)
|
||||
foreach ($p in $legacyPaths) {
|
||||
Warn-IfReparsePresent $p
|
||||
}
|
||||
|
||||
# Skills runtime checks (junctions or symlinks into runtime-specific dirs)
|
||||
$linkTargets = @(
|
||||
(Join-Path $env:USERPROFILE ".claude\skills"),
|
||||
(Join-Path $env:USERPROFILE ".codex\skills"),
|
||||
(Join-Path $env:USERPROFILE ".config\opencode\skills")
|
||||
)
|
||||
|
||||
$skillSources = @($MosaicHome + "\skills", $MosaicHome + "\skills-local")
|
||||
|
||||
foreach ($runtimeSkills in $linkTargets) {
|
||||
if (-not (Test-Path $runtimeSkills)) { continue }
|
||||
|
||||
foreach ($sourceDir in $skillSources) {
|
||||
if (-not (Test-Path $sourceDir)) { continue }
|
||||
|
||||
Get-ChildItem $sourceDir -Directory | Where-Object { -not $_.Name.StartsWith(".") } | ForEach-Object {
|
||||
$name = $_.Name
|
||||
$skillPath = $_.FullName
|
||||
$target = Join-Path $runtimeSkills $name
|
||||
|
||||
if (-not (Test-Path $target)) {
|
||||
Warn "Missing skill link: $target"
|
||||
return
|
||||
}
|
||||
|
||||
$item = Get-Item $target -Force -ErrorAction SilentlyContinue
|
||||
if (-not ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
||||
Warn "Non-junction skill entry: $target"
|
||||
return
|
||||
}
|
||||
|
||||
$targetResolved = $item.Target
|
||||
if (-not $targetResolved -or (Resolve-Path $targetResolved -ErrorAction SilentlyContinue).Path -ne (Resolve-Path $skillPath -ErrorAction SilentlyContinue).Path) {
|
||||
Warn "Drifted skill link: $target (expected -> $skillPath)"
|
||||
}
|
||||
else {
|
||||
Pass "Linked skill: $target"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Broken junctions/symlinks in managed runtime skill dirs
|
||||
$brokenLinks = 0
|
||||
foreach ($d in $linkTargets) {
|
||||
if (-not (Test-Path $d)) { continue }
|
||||
Get-ChildItem $d -Force -ErrorAction SilentlyContinue | Where-Object {
|
||||
($_.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -and -not (Test-Path $_.FullName)
|
||||
} | ForEach-Object { $brokenLinks++ }
|
||||
}
|
||||
if ($brokenLinks -gt 0) {
|
||||
Warn "Broken skill junctions/symlinks detected: $brokenLinks"
|
||||
}
|
||||
|
||||
Write-Host "[mosaic-doctor] warnings=$($script:warnCount)"
|
||||
if ($FailOnWarn -and $script:warnCount -gt 0) {
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
TOOLS_DIR="$MOSAIC_HOME/tools/excalidraw"
|
||||
MODE="apply"
|
||||
SCOPE="user"
|
||||
|
||||
err() { echo "[mosaic-excalidraw] ERROR: $*" >&2; }
|
||||
log() { echo "[mosaic-excalidraw] $*"; }
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--check) MODE="check"; shift ;;
|
||||
--scope)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
err "--scope requires a value: user|local"
|
||||
exit 2
|
||||
fi
|
||||
SCOPE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
err "Unknown argument: $1"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_binary() {
|
||||
local name="$1"
|
||||
if ! command -v "$name" >/dev/null 2>&1; then
|
||||
err "Required binary missing: $name"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_software() {
|
||||
require_binary node
|
||||
require_binary npm
|
||||
}
|
||||
|
||||
check_tool_dir() {
|
||||
[[ -d "$TOOLS_DIR" ]] || { err "Tool dir not found: $TOOLS_DIR"; return 1; }
|
||||
[[ -f "$TOOLS_DIR/package.json" ]] || { err "package.json not found in $TOOLS_DIR"; return 1; }
|
||||
[[ -f "$TOOLS_DIR/launch.sh" ]] || { err "launch.sh not found in $TOOLS_DIR"; return 1; }
|
||||
}
|
||||
|
||||
check_npm_deps() {
|
||||
[[ -d "$TOOLS_DIR/node_modules/@modelcontextprotocol" ]] || return 1
|
||||
[[ -d "$TOOLS_DIR/node_modules/@excalidraw" ]] || return 1
|
||||
[[ -d "$TOOLS_DIR/node_modules/jsdom" ]] || return 1
|
||||
}
|
||||
|
||||
install_npm_deps() {
|
||||
if check_npm_deps; then
|
||||
return 0
|
||||
fi
|
||||
log "Installing npm deps in $TOOLS_DIR..."
|
||||
(cd "$TOOLS_DIR" && npm install --silent) || {
|
||||
err "npm install failed in $TOOLS_DIR"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
check_claude_config() {
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
p = Path.home() / ".claude.json"
|
||||
if not p.exists():
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
raise SystemExit(1)
|
||||
mcp = data.get("mcpServers")
|
||||
if not isinstance(mcp, dict):
|
||||
raise SystemExit(1)
|
||||
entry = mcp.get("excalidraw")
|
||||
if not isinstance(entry, dict):
|
||||
raise SystemExit(1)
|
||||
cmd = entry.get("command", "")
|
||||
if not cmd.endswith("launch.sh"):
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
apply_claude_config() {
|
||||
require_binary claude
|
||||
local launch_sh="$TOOLS_DIR/launch.sh"
|
||||
claude mcp add --scope user excalidraw -- "$launch_sh"
|
||||
}
|
||||
|
||||
# ── Check mode ────────────────────────────────────────────────────────────────
|
||||
|
||||
if [[ "$MODE" == "check" ]]; then
|
||||
check_software
|
||||
check_tool_dir
|
||||
if ! check_npm_deps; then
|
||||
err "npm deps not installed in $TOOLS_DIR (run without --check to install)"
|
||||
exit 1
|
||||
fi
|
||||
if ! check_claude_config; then
|
||||
err "excalidraw not registered in ~/.claude.json"
|
||||
exit 1
|
||||
fi
|
||||
log "excalidraw MCP is configured and available"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Apply mode ────────────────────────────────────────────────────────────────
|
||||
|
||||
check_software
|
||||
check_tool_dir
|
||||
install_npm_deps
|
||||
apply_claude_config
|
||||
log "excalidraw MCP configured (scope: $SCOPE)"
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
MODE="apply"
|
||||
RUNTIME="all"
|
||||
STRICT_CHECK=0
|
||||
|
||||
PKG="@modelcontextprotocol/server-sequential-thinking"
|
||||
|
||||
err() { echo "[mosaic-seq] ERROR: $*" >&2; }
|
||||
log() { echo "[mosaic-seq] $*"; }
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--check)
|
||||
MODE="check"
|
||||
shift
|
||||
;;
|
||||
--runtime)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
err "--runtime requires a value: claude|codex|opencode|all"
|
||||
exit 2
|
||||
fi
|
||||
RUNTIME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--strict)
|
||||
STRICT_CHECK=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
err "Unknown argument: $1"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$RUNTIME" in
|
||||
all|claude|codex|opencode) ;;
|
||||
*)
|
||||
err "Invalid runtime: $RUNTIME (expected claude|codex|opencode|all)"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
require_binary() {
|
||||
local name="$1"
|
||||
if ! command -v "$name" >/dev/null 2>&1; then
|
||||
err "Required binary missing: $name"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_software() {
|
||||
require_binary node
|
||||
require_binary npx
|
||||
}
|
||||
|
||||
warm_package() {
|
||||
local timeout_sec="${MOSAIC_SEQ_WARM_TIMEOUT_SEC:-15}"
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$timeout_sec" npx -y "$PKG" --help >/dev/null 2>&1
|
||||
else
|
||||
npx -y "$PKG" --help >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
check_claude_config() {
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
p = Path.home() / ".claude" / "settings.json"
|
||||
if not p.exists():
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
raise SystemExit(1)
|
||||
mcp = data.get("mcpServers")
|
||||
if not isinstance(mcp, dict):
|
||||
raise SystemExit(1)
|
||||
entry = mcp.get("sequential-thinking")
|
||||
if not isinstance(entry, dict):
|
||||
raise SystemExit(1)
|
||||
if entry.get("command") != "npx":
|
||||
raise SystemExit(1)
|
||||
args = entry.get("args")
|
||||
if args != ["-y", "@modelcontextprotocol/server-sequential-thinking"]:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
apply_claude_config() {
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
p = Path.home() / ".claude" / "settings.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
if p.exists():
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
data = {}
|
||||
else:
|
||||
data = {}
|
||||
mcp = data.get("mcpServers")
|
||||
if not isinstance(mcp, dict):
|
||||
mcp = {}
|
||||
mcp["sequential-thinking"] = {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
|
||||
}
|
||||
data["mcpServers"] = mcp
|
||||
p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
PY
|
||||
}
|
||||
|
||||
check_codex_config() {
|
||||
local cfg="$HOME/.codex/config.toml"
|
||||
[[ -f "$cfg" ]] || return 1
|
||||
grep -Eq '^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]' "$cfg" && \
|
||||
grep -q '^command = "npx"' "$cfg" && \
|
||||
grep -q '@modelcontextprotocol/server-sequential-thinking' "$cfg"
|
||||
}
|
||||
|
||||
apply_codex_config() {
|
||||
local cfg="$HOME/.codex/config.toml"
|
||||
mkdir -p "$(dirname "$cfg")"
|
||||
[[ -f "$cfg" ]] || touch "$cfg"
|
||||
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
awk '
|
||||
BEGIN { skip = 0 }
|
||||
/^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]/ { skip = 1; next }
|
||||
skip && /^\[/ { skip = 0 }
|
||||
!skip { print }
|
||||
' "$cfg" > "$tmp"
|
||||
mv "$tmp" "$cfg"
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "[mcp_servers.sequential-thinking]"
|
||||
echo "command = \"npx\""
|
||||
echo "args = [\"-y\", \"@modelcontextprotocol/server-sequential-thinking\"]"
|
||||
} >> "$cfg"
|
||||
}
|
||||
|
||||
check_opencode_config() {
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
p = Path.home() / ".config" / "opencode" / "config.json"
|
||||
if not p.exists():
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
raise SystemExit(1)
|
||||
mcp = data.get("mcp")
|
||||
if not isinstance(mcp, dict):
|
||||
raise SystemExit(1)
|
||||
entry = mcp.get("sequential-thinking")
|
||||
if not isinstance(entry, dict):
|
||||
raise SystemExit(1)
|
||||
if entry.get("type") != "local":
|
||||
raise SystemExit(1)
|
||||
if entry.get("command") != ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"]:
|
||||
raise SystemExit(1)
|
||||
if entry.get("enabled") is not True:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
apply_opencode_config() {
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
p = Path.home() / ".config" / "opencode" / "config.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
if p.exists():
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
data = {}
|
||||
else:
|
||||
data = {}
|
||||
mcp = data.get("mcp")
|
||||
if not isinstance(mcp, dict):
|
||||
mcp = {}
|
||||
mcp["sequential-thinking"] = {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"],
|
||||
"enabled": True
|
||||
}
|
||||
data["mcp"] = mcp
|
||||
p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
PY
|
||||
}
|
||||
|
||||
check_runtime_config() {
|
||||
case "$RUNTIME" in
|
||||
all)
|
||||
check_claude_config
|
||||
check_codex_config
|
||||
check_opencode_config
|
||||
;;
|
||||
claude)
|
||||
check_claude_config
|
||||
;;
|
||||
codex)
|
||||
check_codex_config
|
||||
;;
|
||||
opencode)
|
||||
check_opencode_config
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
apply_runtime_config() {
|
||||
case "$RUNTIME" in
|
||||
all)
|
||||
apply_claude_config
|
||||
apply_codex_config
|
||||
apply_opencode_config
|
||||
;;
|
||||
claude)
|
||||
apply_claude_config
|
||||
;;
|
||||
codex)
|
||||
apply_codex_config
|
||||
;;
|
||||
opencode)
|
||||
apply_opencode_config
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [[ "$MODE" == "check" ]]; then
|
||||
check_software
|
||||
check_runtime_config
|
||||
|
||||
# Runtime launch checks should be local/fast by default.
|
||||
if [[ "$STRICT_CHECK" -eq 1 || "${MOSAIC_SEQ_CHECK_WARM:-0}" == "1" ]]; then
|
||||
if ! warm_package; then
|
||||
err "sequential-thinking package warm-up failed in strict mode"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log "sequential-thinking MCP is configured and available (${RUNTIME})"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
check_software
|
||||
if ! warm_package; then
|
||||
err "Unable to warm sequential-thinking package (npx timeout/failure)"
|
||||
exit 1
|
||||
fi
|
||||
apply_runtime_config
|
||||
log "sequential-thinking MCP configured (${RUNTIME})"
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
# mosaic-ensure-sequential-thinking.ps1
|
||||
param(
|
||||
[switch]$Check
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Pkg = "@modelcontextprotocol/server-sequential-thinking"
|
||||
|
||||
function Require-Binary {
|
||||
param([string]$Name)
|
||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||||
throw "Required binary missing: $Name"
|
||||
}
|
||||
}
|
||||
|
||||
function Warm-Package {
|
||||
$null = & npx -y $Pkg --help 2>$null
|
||||
}
|
||||
|
||||
function Set-ClaudeConfig {
|
||||
$path = Join-Path $env:USERPROFILE ".claude\settings.json"
|
||||
New-Item -ItemType Directory -Path (Split-Path $path -Parent) -Force | Out-Null
|
||||
|
||||
$data = @{}
|
||||
if (Test-Path $path) {
|
||||
try { $data = Get-Content $path -Raw | ConvertFrom-Json -AsHashtable } catch { $data = @{} }
|
||||
}
|
||||
if (-not $data.ContainsKey("mcpServers") -or -not ($data["mcpServers"] -is [hashtable])) {
|
||||
$data["mcpServers"] = @{}
|
||||
}
|
||||
$data["mcpServers"]["sequential-thinking"] = @{
|
||||
command = "npx"
|
||||
args = @("-y", "@modelcontextprotocol/server-sequential-thinking")
|
||||
}
|
||||
$data | ConvertTo-Json -Depth 20 | Set-Content -Path $path -Encoding UTF8
|
||||
}
|
||||
|
||||
function Set-CodexConfig {
|
||||
$path = Join-Path $env:USERPROFILE ".codex\config.toml"
|
||||
New-Item -ItemType Directory -Path (Split-Path $path -Parent) -Force | Out-Null
|
||||
if (-not (Test-Path $path)) { New-Item -ItemType File -Path $path -Force | Out-Null }
|
||||
|
||||
$content = Get-Content $path -Raw
|
||||
$content = [regex]::Replace($content, "(?ms)^\[mcp_servers\.(sequential-thinking|sequential_thinking)\].*?(?=^\[|\z)", "")
|
||||
$content = $content.TrimEnd() + "`n`n[mcp_servers.sequential-thinking]`ncommand = `"npx`"`nargs = [`"-y`", `"@modelcontextprotocol/server-sequential-thinking`"]`n"
|
||||
Set-Content -Path $path -Value $content -Encoding UTF8
|
||||
}
|
||||
|
||||
function Set-OpenCodeConfig {
|
||||
$path = Join-Path $env:USERPROFILE ".config\opencode\config.json"
|
||||
New-Item -ItemType Directory -Path (Split-Path $path -Parent) -Force | Out-Null
|
||||
|
||||
$data = @{}
|
||||
if (Test-Path $path) {
|
||||
try { $data = Get-Content $path -Raw | ConvertFrom-Json -AsHashtable } catch { $data = @{} }
|
||||
}
|
||||
if (-not $data.ContainsKey("mcp") -or -not ($data["mcp"] -is [hashtable])) {
|
||||
$data["mcp"] = @{}
|
||||
}
|
||||
$data["mcp"]["sequential-thinking"] = @{
|
||||
type = "local"
|
||||
command = @("npx", "-y", "@modelcontextprotocol/server-sequential-thinking")
|
||||
enabled = $true
|
||||
}
|
||||
$data | ConvertTo-Json -Depth 20 | Set-Content -Path $path -Encoding UTF8
|
||||
}
|
||||
|
||||
function Test-Configs {
|
||||
$claudeOk = $false
|
||||
$codexOk = $false
|
||||
$opencodeOk = $false
|
||||
|
||||
$claudePath = Join-Path $env:USERPROFILE ".claude\settings.json"
|
||||
if (Test-Path $claudePath) {
|
||||
try {
|
||||
$c = Get-Content $claudePath -Raw | ConvertFrom-Json -AsHashtable
|
||||
$claudeOk = $c.ContainsKey("mcpServers") -and $c["mcpServers"].ContainsKey("sequential-thinking")
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$codexPath = Join-Path $env:USERPROFILE ".codex\config.toml"
|
||||
if (Test-Path $codexPath) {
|
||||
$raw = Get-Content $codexPath -Raw
|
||||
$codexOk = $raw -match "\[mcp_servers\.(sequential-thinking|sequential_thinking)\]" -and $raw -match "@modelcontextprotocol/server-sequential-thinking"
|
||||
}
|
||||
|
||||
$opencodePath = Join-Path $env:USERPROFILE ".config\opencode\config.json"
|
||||
if (Test-Path $opencodePath) {
|
||||
try {
|
||||
$o = Get-Content $opencodePath -Raw | ConvertFrom-Json -AsHashtable
|
||||
$opencodeOk = $o.ContainsKey("mcp") -and $o["mcp"].ContainsKey("sequential-thinking")
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (-not ($claudeOk -and $codexOk -and $opencodeOk)) {
|
||||
throw "Sequential-thinking MCP runtime config is incomplete"
|
||||
}
|
||||
}
|
||||
|
||||
Require-Binary node
|
||||
Require-Binary npx
|
||||
Warm-Package
|
||||
|
||||
if ($Check) {
|
||||
Test-Configs
|
||||
Write-Host "[mosaic-seq] sequential-thinking MCP is configured and available"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Set-ClaudeConfig
|
||||
Set-CodexConfig
|
||||
Set-OpenCodeConfig
|
||||
Write-Host "[mosaic-seq] sequential-thinking MCP configured for Claude, Codex, and OpenCode"
|
||||
+562
@@ -0,0 +1,562 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# mosaic-init — Interactive agent identity, user profile, and tool config generator
|
||||
#
|
||||
# Usage:
|
||||
# mosaic-init # Interactive mode
|
||||
# mosaic-init --name "Mosaic Agent" --style direct # Flag overrides
|
||||
# mosaic-init --name "Mosaic Agent" --role "memory steward" --style direct \
|
||||
# --accessibility "ADHD-friendly chunking" --guardrails "Never auto-commit"
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
SOUL_TEMPLATE="$MOSAIC_HOME/templates/SOUL.md.template"
|
||||
USER_TEMPLATE="$MOSAIC_HOME/templates/USER.md.template"
|
||||
TOOLS_TEMPLATE="$MOSAIC_HOME/templates/TOOLS.md.template"
|
||||
SOUL_OUTPUT="$MOSAIC_HOME/SOUL.md"
|
||||
USER_OUTPUT="$MOSAIC_HOME/USER.md"
|
||||
TOOLS_OUTPUT="$MOSAIC_HOME/TOOLS.md"
|
||||
|
||||
# Defaults
|
||||
AGENT_NAME=""
|
||||
ROLE_DESCRIPTION=""
|
||||
STYLE=""
|
||||
ACCESSIBILITY=""
|
||||
CUSTOM_GUARDRAILS=""
|
||||
|
||||
# USER.md defaults
|
||||
USER_NAME=""
|
||||
PRONOUNS=""
|
||||
TIMEZONE=""
|
||||
BACKGROUND=""
|
||||
COMMUNICATION_PREFS=""
|
||||
PERSONAL_BOUNDARIES=""
|
||||
PROJECTS_TABLE=""
|
||||
|
||||
# TOOLS.md defaults
|
||||
GIT_PROVIDERS_TABLE=""
|
||||
CREDENTIALS_LOCATION=""
|
||||
CUSTOM_TOOLS_SECTION=""
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") [options]
|
||||
|
||||
Generate Mosaic identity and configuration files:
|
||||
- SOUL.md — Agent identity contract
|
||||
- USER.md — User profile and accessibility
|
||||
- TOOLS.md — Machine-level tool reference
|
||||
|
||||
Interactive by default. Use flags to skip prompts.
|
||||
|
||||
Options:
|
||||
--name <name> Agent name (e.g., "Mosaic Agent", "Assistant")
|
||||
--role <description> Role description (e.g., "memory steward, execution partner")
|
||||
--style <style> Communication style: direct, friendly, or formal
|
||||
--accessibility <prefs> Accessibility preferences (e.g., "ADHD-friendly chunking")
|
||||
--guardrails <rules> Custom guardrails (appended to defaults)
|
||||
--user-name <name> Your name for USER.md
|
||||
--pronouns <pronouns> Your pronouns (e.g., "He/Him")
|
||||
--timezone <tz> Your timezone (e.g., "America/Chicago")
|
||||
--non-interactive Fail if any required value is missing (no prompts)
|
||||
--soul-only Only generate SOUL.md
|
||||
--force Overwrite existing files without prompting
|
||||
-h, --help Show help
|
||||
USAGE
|
||||
}
|
||||
|
||||
NON_INTERACTIVE=0
|
||||
SOUL_ONLY=0
|
||||
FORCE=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) AGENT_NAME="$2"; shift 2 ;;
|
||||
--role) ROLE_DESCRIPTION="$2"; shift 2 ;;
|
||||
--style) STYLE="$2"; shift 2 ;;
|
||||
--accessibility) ACCESSIBILITY="$2"; shift 2 ;;
|
||||
--guardrails) CUSTOM_GUARDRAILS="$2"; shift 2 ;;
|
||||
--user-name) USER_NAME="$2"; shift 2 ;;
|
||||
--pronouns) PRONOUNS="$2"; shift 2 ;;
|
||||
--timezone) TIMEZONE="$2"; shift 2 ;;
|
||||
--non-interactive) NON_INTERACTIVE=1; shift ;;
|
||||
--soul-only) SOUL_ONLY=1; shift ;;
|
||||
--force) FORCE=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
prompt_if_empty() {
|
||||
local var_name="$1"
|
||||
local prompt_text="$2"
|
||||
local default_value="${3:-}"
|
||||
local current_value="${!var_name}"
|
||||
|
||||
if [[ -n "$current_value" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ $NON_INTERACTIVE -eq 1 ]]; then
|
||||
if [[ -n "$default_value" ]]; then
|
||||
printf -v "$var_name" %s "$default_value"
|
||||
return
|
||||
fi
|
||||
echo "[mosaic-init] ERROR: --$var_name is required in non-interactive mode" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$default_value" ]]; then
|
||||
prompt_text="$prompt_text [$default_value]"
|
||||
fi
|
||||
|
||||
printf "%s: " "$prompt_text"
|
||||
read -r value
|
||||
if [[ -z "$value" && -n "$default_value" ]]; then
|
||||
value="$default_value"
|
||||
fi
|
||||
printf -v "$var_name" %s "$value"
|
||||
}
|
||||
|
||||
prompt_multiline() {
|
||||
local var_name="$1"
|
||||
local prompt_text="$2"
|
||||
local default_value="${3:-}"
|
||||
local current_value="${!var_name}"
|
||||
|
||||
if [[ -n "$current_value" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ $NON_INTERACTIVE -eq 1 ]]; then
|
||||
printf -v "$var_name" %s "$default_value"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "$prompt_text"
|
||||
printf "(Press Enter to skip, or type your response): "
|
||||
read -r value
|
||||
if [[ -z "$value" ]]; then
|
||||
value="$default_value"
|
||||
fi
|
||||
printf -v "$var_name" %s "$value"
|
||||
}
|
||||
|
||||
# ── Existing file detection ────────────────────────────────────
|
||||
|
||||
detect_existing_config() {
|
||||
local found=0
|
||||
local existing_files=()
|
||||
|
||||
[[ -f "$SOUL_OUTPUT" ]] && { found=1; existing_files+=("SOUL.md"); }
|
||||
[[ -f "$USER_OUTPUT" ]] && { found=1; existing_files+=("USER.md"); }
|
||||
[[ -f "$TOOLS_OUTPUT" ]] && { found=1; existing_files+=("TOOLS.md"); }
|
||||
|
||||
if [[ $found -eq 0 || $FORCE -eq 1 ]]; then
|
||||
return 0 # No existing files or --force: proceed with fresh install
|
||||
fi
|
||||
|
||||
echo "[mosaic-init] Existing configuration detected:"
|
||||
for f in "${existing_files[@]}"; do
|
||||
echo " ✓ $f"
|
||||
done
|
||||
|
||||
# Show current agent name if SOUL.md exists
|
||||
if [[ -f "$SOUL_OUTPUT" ]]; then
|
||||
local current_name
|
||||
current_name=$(grep -oP 'You are \*\*\K[^*]+' "$SOUL_OUTPUT" 2>/dev/null || true)
|
||||
if [[ -n "$current_name" ]]; then
|
||||
echo " Agent: $current_name"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if [[ $NON_INTERACTIVE -eq 1 ]]; then
|
||||
echo "[mosaic-init] Existing config found. Use --force to overwrite in non-interactive mode."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "What would you like to do?"
|
||||
echo " 1) keep — Keep existing files, skip init (default)"
|
||||
echo " 2) import — Import values from existing files as defaults, then regenerate"
|
||||
echo " 3) overwrite — Start fresh, overwrite all files"
|
||||
printf "Choose [1/2/3]: "
|
||||
read -r choice
|
||||
|
||||
case "${choice:-1}" in
|
||||
1|keep)
|
||||
echo "[mosaic-init] Keeping existing configuration."
|
||||
# Still push to runtime adapters in case framework was updated
|
||||
if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" ]]; then
|
||||
echo "[mosaic-init] Updating runtime adapters..."
|
||||
"$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets"
|
||||
fi
|
||||
echo "[mosaic-init] Done. Launch with: mosaic claude"
|
||||
exit 0
|
||||
;;
|
||||
2|import)
|
||||
echo "[mosaic-init] Importing values from existing files as defaults..."
|
||||
import_existing_values
|
||||
;;
|
||||
3|overwrite)
|
||||
echo "[mosaic-init] Starting fresh install..."
|
||||
# Back up existing files
|
||||
local ts
|
||||
ts=$(date +%Y%m%d%H%M%S)
|
||||
for f in "${existing_files[@]}"; do
|
||||
local src="$MOSAIC_HOME/$f"
|
||||
if [[ -f "$src" ]]; then
|
||||
cp "$src" "${src}.bak.${ts}"
|
||||
echo " Backed up $f → ${f}.bak.${ts}"
|
||||
fi
|
||||
done
|
||||
;;
|
||||
*)
|
||||
echo "[mosaic-init] Invalid choice. Keeping existing configuration."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
import_existing_values() {
|
||||
# Import SOUL.md values
|
||||
if [[ -f "$SOUL_OUTPUT" ]]; then
|
||||
local content
|
||||
content=$(cat "$SOUL_OUTPUT")
|
||||
|
||||
if [[ -z "$AGENT_NAME" ]]; then
|
||||
AGENT_NAME=$(echo "$content" | grep -oP 'You are \*\*\K[^*]+' 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$ROLE_DESCRIPTION" ]]; then
|
||||
ROLE_DESCRIPTION=$(echo "$content" | grep -oP 'Role identity: \K.+' 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$STYLE" ]]; then
|
||||
if echo "$content" | grep -q 'Be direct, concise'; then
|
||||
STYLE="direct"
|
||||
elif echo "$content" | grep -q 'Be warm and conversational'; then
|
||||
STYLE="friendly"
|
||||
elif echo "$content" | grep -q 'Use professional, structured'; then
|
||||
STYLE="formal"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Import USER.md values
|
||||
if [[ -f "$USER_OUTPUT" ]]; then
|
||||
local content
|
||||
content=$(cat "$USER_OUTPUT")
|
||||
|
||||
if [[ -z "$USER_NAME" ]]; then
|
||||
USER_NAME=$(echo "$content" | grep -oP '\*\*Name:\*\* \K.+' 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$PRONOUNS" ]]; then
|
||||
PRONOUNS=$(echo "$content" | grep -oP '\*\*Pronouns:\*\* \K.+' 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$TIMEZONE" ]]; then
|
||||
TIMEZONE=$(echo "$content" | grep -oP '\*\*Timezone:\*\* \K.+' 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
|
||||
# Import TOOLS.md values
|
||||
if [[ -f "$TOOLS_OUTPUT" ]]; then
|
||||
local content
|
||||
content=$(cat "$TOOLS_OUTPUT")
|
||||
|
||||
if [[ -z "$CREDENTIALS_LOCATION" ]]; then
|
||||
CREDENTIALS_LOCATION=$(echo "$content" | grep -oP '\*\*Location:\*\* \K.+' 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
detect_existing_config
|
||||
|
||||
# ── SOUL.md Generation ────────────────────────────────────────
|
||||
echo "[mosaic-init] Generating SOUL.md — agent identity contract"
|
||||
echo ""
|
||||
|
||||
# Fail-closed persona: in non-interactive mode the agent NAME must be supplied
|
||||
# explicitly (--name) — never silently ship an agent named "Assistant".
|
||||
if [[ $NON_INTERACTIVE -eq 1 && -z "$AGENT_NAME" ]]; then
|
||||
echo "[mosaic-init] ERROR: --name (agent name) is required in non-interactive mode." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
prompt_if_empty AGENT_NAME "What name should agents use" "Assistant"
|
||||
prompt_if_empty ROLE_DESCRIPTION "Agent role description" "execution partner and visibility engine"
|
||||
|
||||
if [[ -z "$STYLE" && $NON_INTERACTIVE -eq 0 ]]; then
|
||||
echo ""
|
||||
echo "Communication style:"
|
||||
echo " 1) direct — Concise, no fluff, actionable"
|
||||
echo " 2) friendly — Warm but efficient, conversational"
|
||||
echo " 3) formal — Professional, structured, thorough"
|
||||
printf "Choose [1/2/3] (default: 1): "
|
||||
read -r style_choice
|
||||
case "${style_choice:-1}" in
|
||||
1|direct) STYLE="direct" ;;
|
||||
2|friendly) STYLE="friendly" ;;
|
||||
3|formal) STYLE="formal" ;;
|
||||
*) STYLE="direct" ;;
|
||||
esac
|
||||
elif [[ -z "$STYLE" ]]; then
|
||||
STYLE="direct"
|
||||
fi
|
||||
|
||||
prompt_if_empty ACCESSIBILITY "Accessibility preferences (or 'none')" "none"
|
||||
|
||||
if [[ $NON_INTERACTIVE -eq 0 && -z "$CUSTOM_GUARDRAILS" ]]; then
|
||||
echo ""
|
||||
printf "Custom guardrails (optional, press Enter to skip): "
|
||||
read -r CUSTOM_GUARDRAILS
|
||||
fi
|
||||
|
||||
# Build behavioral principles based on style + accessibility
|
||||
BEHAVIORAL_PRINCIPLES=""
|
||||
case "$STYLE" in
|
||||
direct)
|
||||
BEHAVIORAL_PRINCIPLES="1. Clarity over performance theater.
|
||||
2. Practical execution over abstract planning.
|
||||
3. Truthfulness over confidence: state uncertainty explicitly.
|
||||
4. Visible state over hidden assumptions.
|
||||
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations."
|
||||
;;
|
||||
friendly)
|
||||
BEHAVIORAL_PRINCIPLES="1. Be helpful and approachable while staying efficient.
|
||||
2. Provide context and explain reasoning when helpful.
|
||||
3. Truthfulness over confidence: state uncertainty explicitly.
|
||||
4. Visible state over hidden assumptions.
|
||||
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations."
|
||||
;;
|
||||
formal)
|
||||
BEHAVIORAL_PRINCIPLES="1. Maintain professional, structured communication.
|
||||
2. Provide thorough analysis with explicit tradeoffs.
|
||||
3. Truthfulness over confidence: state uncertainty explicitly.
|
||||
4. Document decisions and rationale clearly.
|
||||
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations."
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$ACCESSIBILITY" != "none" && -n "$ACCESSIBILITY" ]]; then
|
||||
BEHAVIORAL_PRINCIPLES="$BEHAVIORAL_PRINCIPLES
|
||||
6. $ACCESSIBILITY."
|
||||
fi
|
||||
|
||||
# Build communication style section
|
||||
COMMUNICATION_STYLE=""
|
||||
case "$STYLE" in
|
||||
direct)
|
||||
COMMUNICATION_STYLE="- Be direct, concise, and concrete.
|
||||
- Avoid fluff, hype, and anthropomorphic roleplay.
|
||||
- Do not simulate certainty when facts are missing.
|
||||
- Prefer actionable next steps and explicit tradeoffs."
|
||||
;;
|
||||
friendly)
|
||||
COMMUNICATION_STYLE="- Be warm and conversational while staying focused.
|
||||
- Explain your reasoning when it helps the user.
|
||||
- Do not simulate certainty when facts are missing.
|
||||
- Prefer actionable next steps with clear context."
|
||||
;;
|
||||
formal)
|
||||
COMMUNICATION_STYLE="- Use professional, structured language.
|
||||
- Provide thorough explanations with supporting detail.
|
||||
- Do not simulate certainty when facts are missing.
|
||||
- Present options with explicit tradeoffs and recommendations."
|
||||
;;
|
||||
esac
|
||||
|
||||
# Format custom guardrails
|
||||
FORMATTED_GUARDRAILS=""
|
||||
if [[ -n "$CUSTOM_GUARDRAILS" ]]; then
|
||||
FORMATTED_GUARDRAILS="- $CUSTOM_GUARDRAILS"
|
||||
fi
|
||||
|
||||
# Verify template exists
|
||||
if [[ ! -f "$SOUL_TEMPLATE" ]]; then
|
||||
echo "[mosaic-init] ERROR: Template not found: $SOUL_TEMPLATE" >&2
|
||||
echo "[mosaic-init] Run the Mosaic installer first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate SOUL.md from template using awk (handles multi-line values)
|
||||
awk -v name="$AGENT_NAME" \
|
||||
-v role="$ROLE_DESCRIPTION" \
|
||||
-v principles="$BEHAVIORAL_PRINCIPLES" \
|
||||
-v comms="$COMMUNICATION_STYLE" \
|
||||
-v guardrails="$FORMATTED_GUARDRAILS" \
|
||||
'{
|
||||
gsub(/\{\{AGENT_NAME\}\}/, name)
|
||||
gsub(/\{\{ROLE_DESCRIPTION\}\}/, role)
|
||||
gsub(/\{\{BEHAVIORAL_PRINCIPLES\}\}/, principles)
|
||||
gsub(/\{\{COMMUNICATION_STYLE\}\}/, comms)
|
||||
gsub(/\{\{CUSTOM_GUARDRAILS\}\}/, guardrails)
|
||||
print
|
||||
}' "$SOUL_TEMPLATE" > "$SOUL_OUTPUT"
|
||||
|
||||
echo ""
|
||||
echo "[mosaic-init] Generated: $SOUL_OUTPUT"
|
||||
echo "[mosaic-init] Agent name: $AGENT_NAME"
|
||||
echo "[mosaic-init] Style: $STYLE"
|
||||
|
||||
if [[ $SOUL_ONLY -eq 1 ]]; then
|
||||
# Push to runtime adapters and exit
|
||||
if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" ]]; then
|
||||
echo "[mosaic-init] Updating runtime adapters..."
|
||||
"$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets"
|
||||
fi
|
||||
echo "[mosaic-init] Done. Launch with: mosaic claude"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── USER.md Generation ────────────────────────────────────────
|
||||
echo ""
|
||||
echo "[mosaic-init] Generating USER.md — user profile"
|
||||
echo ""
|
||||
|
||||
prompt_if_empty USER_NAME "Your name" ""
|
||||
prompt_if_empty PRONOUNS "Your pronouns" "They/Them"
|
||||
prompt_if_empty TIMEZONE "Your timezone" "UTC"
|
||||
|
||||
prompt_multiline BACKGROUND "Your professional background (brief summary)" "(not configured)"
|
||||
|
||||
# Build accessibility section
|
||||
ACCESSIBILITY_SECTION=""
|
||||
if [[ "$ACCESSIBILITY" != "none" && -n "$ACCESSIBILITY" ]]; then
|
||||
ACCESSIBILITY_SECTION="$ACCESSIBILITY"
|
||||
else
|
||||
if [[ $NON_INTERACTIVE -eq 0 ]]; then
|
||||
echo ""
|
||||
prompt_multiline ACCESSIBILITY_SECTION \
|
||||
"Accessibility or neurodivergence accommodations (or press Enter to skip)" \
|
||||
"(No specific accommodations configured. Edit this section to add any.)"
|
||||
else
|
||||
ACCESSIBILITY_SECTION="(No specific accommodations configured. Edit this section to add any.)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build communication preferences
|
||||
if [[ -z "$COMMUNICATION_PREFS" ]]; then
|
||||
case "$STYLE" in
|
||||
direct)
|
||||
COMMUNICATION_PREFS="- Direct and concise
|
||||
- No sycophancy
|
||||
- Executive summaries and tables for overview"
|
||||
;;
|
||||
friendly)
|
||||
COMMUNICATION_PREFS="- Warm and conversational
|
||||
- Explain reasoning when helpful
|
||||
- Balance thoroughness with brevity"
|
||||
;;
|
||||
formal)
|
||||
COMMUNICATION_PREFS="- Professional and structured
|
||||
- Thorough explanations with supporting detail
|
||||
- Formal tone with explicit recommendations"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
prompt_multiline PERSONAL_BOUNDARIES \
|
||||
"Personal boundaries or preferences agents should respect" \
|
||||
"(Edit this section to add any personal boundaries.)"
|
||||
|
||||
if [[ -z "$PROJECTS_TABLE" ]]; then
|
||||
PROJECTS_TABLE="| Project | Stack | Registry |
|
||||
|---------|-------|----------|
|
||||
| (none configured) | | |"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$USER_TEMPLATE" ]]; then
|
||||
echo "[mosaic-init] WARN: USER.md template not found: $USER_TEMPLATE" >&2
|
||||
echo "[mosaic-init] Skipping USER.md generation." >&2
|
||||
else
|
||||
awk -v user_name="$USER_NAME" \
|
||||
-v pronouns="$PRONOUNS" \
|
||||
-v timezone="$TIMEZONE" \
|
||||
-v background="$BACKGROUND" \
|
||||
-v accessibility="$ACCESSIBILITY_SECTION" \
|
||||
-v comms="$COMMUNICATION_PREFS" \
|
||||
-v boundaries="$PERSONAL_BOUNDARIES" \
|
||||
-v projects="$PROJECTS_TABLE" \
|
||||
'{
|
||||
gsub(/\{\{USER_NAME\}\}/, user_name)
|
||||
gsub(/\{\{PRONOUNS\}\}/, pronouns)
|
||||
gsub(/\{\{TIMEZONE\}\}/, timezone)
|
||||
gsub(/\{\{BACKGROUND\}\}/, background)
|
||||
gsub(/\{\{ACCESSIBILITY_SECTION\}\}/, accessibility)
|
||||
gsub(/\{\{COMMUNICATION_PREFS\}\}/, comms)
|
||||
gsub(/\{\{PERSONAL_BOUNDARIES\}\}/, boundaries)
|
||||
gsub(/\{\{PROJECTS_TABLE\}\}/, projects)
|
||||
print
|
||||
}' "$USER_TEMPLATE" > "$USER_OUTPUT"
|
||||
|
||||
echo "[mosaic-init] Generated: $USER_OUTPUT"
|
||||
fi
|
||||
|
||||
# ── TOOLS.md Generation ───────────────────────────────────────
|
||||
echo ""
|
||||
echo "[mosaic-init] Generating TOOLS.md — machine-level tool reference"
|
||||
echo ""
|
||||
|
||||
if [[ -z "$GIT_PROVIDERS_TABLE" ]]; then
|
||||
if [[ $NON_INTERACTIVE -eq 0 ]]; then
|
||||
echo "Git providers (add rows for your Gitea/GitHub/GitLab instances):"
|
||||
printf "Primary git provider URL (or press Enter to skip): "
|
||||
read -r git_url
|
||||
if [[ -n "$git_url" ]]; then
|
||||
printf "Provider name: "
|
||||
read -r git_name
|
||||
printf "CLI tool (tea/gh/glab): "
|
||||
read -r git_cli
|
||||
printf "Purpose: "
|
||||
read -r git_purpose
|
||||
GIT_PROVIDERS_TABLE="| Instance | URL | CLI | Purpose |
|
||||
|----------|-----|-----|---------|
|
||||
| $git_name | $git_url | \`$git_cli\` | $git_purpose |"
|
||||
else
|
||||
GIT_PROVIDERS_TABLE="| Instance | URL | CLI | Purpose |
|
||||
|----------|-----|-----|---------|
|
||||
| (add your git providers here) | | | |"
|
||||
fi
|
||||
else
|
||||
GIT_PROVIDERS_TABLE="| Instance | URL | CLI | Purpose |
|
||||
|----------|-----|-----|---------|
|
||||
| (add your git providers here) | | | |"
|
||||
fi
|
||||
fi
|
||||
|
||||
prompt_if_empty CREDENTIALS_LOCATION "Credential file path (or 'none')" "none"
|
||||
|
||||
if [[ -z "$CUSTOM_TOOLS_SECTION" ]]; then
|
||||
CUSTOM_TOOLS_SECTION="## Custom Tools
|
||||
|
||||
(Add any machine-specific tools, scripts, or workflows here.)"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$TOOLS_TEMPLATE" ]]; then
|
||||
echo "[mosaic-init] WARN: TOOLS.md template not found: $TOOLS_TEMPLATE" >&2
|
||||
echo "[mosaic-init] Skipping TOOLS.md generation." >&2
|
||||
else
|
||||
awk -v providers="$GIT_PROVIDERS_TABLE" \
|
||||
-v creds="$CREDENTIALS_LOCATION" \
|
||||
-v custom="$CUSTOM_TOOLS_SECTION" \
|
||||
'{
|
||||
gsub(/\{\{GIT_PROVIDERS_TABLE\}\}/, providers)
|
||||
gsub(/\{\{CREDENTIALS_LOCATION\}\}/, creds)
|
||||
gsub(/\{\{CUSTOM_TOOLS_SECTION\}\}/, custom)
|
||||
print
|
||||
}' "$TOOLS_TEMPLATE" > "$TOOLS_OUTPUT"
|
||||
|
||||
echo "[mosaic-init] Generated: $TOOLS_OUTPUT"
|
||||
fi
|
||||
|
||||
# ── Finalize ──────────────────────────────────────────────────
|
||||
|
||||
# Push to runtime adapters
|
||||
if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" ]]; then
|
||||
echo ""
|
||||
echo "[mosaic-init] Updating runtime adapters..."
|
||||
"$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[mosaic-init] Done. Launch with: mosaic claude"
|
||||
echo "[mosaic-init] Edit USER.md and TOOLS.md directly for further customization."
|
||||
@@ -0,0 +1,144 @@
|
||||
# mosaic-init.ps1 — Interactive SOUL.md generator (Windows)
|
||||
#
|
||||
# Usage:
|
||||
# mosaic-init.ps1 # Interactive mode
|
||||
# mosaic-init.ps1 -Name "Mosaic Agent" -Style direct # Flag overrides
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
param(
|
||||
[string]$Name,
|
||||
[string]$Role,
|
||||
[ValidateSet("direct", "friendly", "formal")]
|
||||
[string]$Style,
|
||||
[string]$Accessibility,
|
||||
[string]$Guardrails,
|
||||
[switch]$NonInteractive,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$MosaicHome = if ($env:MOSAIC_HOME) { $env:MOSAIC_HOME } else { Join-Path $env:USERPROFILE ".config\mosaic" }
|
||||
$Template = Join-Path $MosaicHome "templates\SOUL.md.template"
|
||||
$Output = Join-Path $MosaicHome "SOUL.md"
|
||||
|
||||
if ($Help) {
|
||||
Write-Host @"
|
||||
Usage: mosaic-init.ps1 [-Name <name>] [-Role <desc>] [-Style direct|friendly|formal]
|
||||
[-Accessibility <prefs>] [-Guardrails <rules>] [-NonInteractive]
|
||||
|
||||
Generate ~/.config/mosaic/SOUL.md - the universal agent identity contract.
|
||||
Interactive by default. Use flags to skip prompts.
|
||||
"@
|
||||
exit 0
|
||||
}
|
||||
|
||||
function Prompt-IfEmpty {
|
||||
param([string]$Current, [string]$PromptText, [string]$Default = "")
|
||||
|
||||
if ($Current) { return $Current }
|
||||
|
||||
if ($NonInteractive) {
|
||||
if ($Default) { return $Default }
|
||||
Write-Host "[mosaic-init] ERROR: Value required in non-interactive mode: $PromptText" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$display = if ($Default) { "$PromptText [$Default]" } else { $PromptText }
|
||||
$value = Read-Host $display
|
||||
if (-not $value -and $Default) { return $Default }
|
||||
return $value
|
||||
}
|
||||
|
||||
Write-Host "[mosaic-init] Generating SOUL.md - your universal agent identity contract"
|
||||
Write-Host ""
|
||||
|
||||
$Name = Prompt-IfEmpty $Name "What name should agents use" "Assistant"
|
||||
$Role = Prompt-IfEmpty $Role "Agent role description" "execution partner and visibility engine"
|
||||
|
||||
if (-not $Style) {
|
||||
if ($NonInteractive) {
|
||||
$Style = "direct"
|
||||
}
|
||||
else {
|
||||
Write-Host ""
|
||||
Write-Host "Communication style:"
|
||||
Write-Host " 1) direct - Concise, no fluff, actionable"
|
||||
Write-Host " 2) friendly - Warm but efficient, conversational"
|
||||
Write-Host " 3) formal - Professional, structured, thorough"
|
||||
$choice = Read-Host "Choose [1/2/3] (default: 1)"
|
||||
$Style = switch ($choice) {
|
||||
"2" { "friendly" }
|
||||
"3" { "formal" }
|
||||
default { "direct" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$Accessibility = Prompt-IfEmpty $Accessibility "Accessibility preferences (or 'none')" "none"
|
||||
|
||||
if (-not $Guardrails -and -not $NonInteractive) {
|
||||
Write-Host ""
|
||||
$Guardrails = Read-Host "Custom guardrails (optional, press Enter to skip)"
|
||||
}
|
||||
|
||||
# Build behavioral principles
|
||||
$BehavioralPrinciples = switch ($Style) {
|
||||
"direct" {
|
||||
"1. Clarity over performance theater.`n2. Practical execution over abstract planning.`n3. Truthfulness over confidence: state uncertainty explicitly.`n4. Visible state over hidden assumptions."
|
||||
}
|
||||
"friendly" {
|
||||
"1. Be helpful and approachable while staying efficient.`n2. Provide context and explain reasoning when helpful.`n3. Truthfulness over confidence: state uncertainty explicitly.`n4. Visible state over hidden assumptions."
|
||||
}
|
||||
"formal" {
|
||||
"1. Maintain professional, structured communication.`n2. Provide thorough analysis with explicit tradeoffs.`n3. Truthfulness over confidence: state uncertainty explicitly.`n4. Document decisions and rationale clearly."
|
||||
}
|
||||
}
|
||||
|
||||
if ($Accessibility -and $Accessibility -ne "none") {
|
||||
$BehavioralPrinciples += "`n5. $Accessibility."
|
||||
}
|
||||
|
||||
# Build communication style
|
||||
$CommunicationStyle = switch ($Style) {
|
||||
"direct" {
|
||||
"- Be direct, concise, and concrete.`n- Avoid fluff, hype, and anthropomorphic roleplay.`n- Do not simulate certainty when facts are missing.`n- Prefer actionable next steps and explicit tradeoffs."
|
||||
}
|
||||
"friendly" {
|
||||
"- Be warm and conversational while staying focused.`n- Explain your reasoning when it helps the user.`n- Do not simulate certainty when facts are missing.`n- Prefer actionable next steps with clear context."
|
||||
}
|
||||
"formal" {
|
||||
"- Use professional, structured language.`n- Provide thorough explanations with supporting detail.`n- Do not simulate certainty when facts are missing.`n- Present options with explicit tradeoffs and recommendations."
|
||||
}
|
||||
}
|
||||
|
||||
# Format custom guardrails
|
||||
$FormattedGuardrails = if ($Guardrails) { "- $Guardrails" } else { "" }
|
||||
|
||||
# Verify template
|
||||
if (-not (Test-Path $Template)) {
|
||||
Write-Host "[mosaic-init] ERROR: Template not found: $Template" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Generate SOUL.md
|
||||
$content = Get-Content $Template -Raw
|
||||
$content = $content -replace '\{\{AGENT_NAME\}\}', $Name
|
||||
$content = $content -replace '\{\{ROLE_DESCRIPTION\}\}', $Role
|
||||
$content = $content -replace '\{\{BEHAVIORAL_PRINCIPLES\}\}', $BehavioralPrinciples
|
||||
$content = $content -replace '\{\{COMMUNICATION_STYLE\}\}', $CommunicationStyle
|
||||
$content = $content -replace '\{\{CUSTOM_GUARDRAILS\}\}', $FormattedGuardrails
|
||||
|
||||
Set-Content -Path $Output -Value $content -Encoding UTF8
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[mosaic-init] Generated: $Output"
|
||||
Write-Host "[mosaic-init] Agent name: $Name"
|
||||
Write-Host "[mosaic-init] Style: $Style"
|
||||
|
||||
# Push to runtime adapters
|
||||
$linkScript = Join-Path $MosaicHome "bin\mosaic-link-runtime-assets.ps1"
|
||||
if (Test-Path $linkScript) {
|
||||
Write-Host "[mosaic-init] Updating runtime adapters..."
|
||||
& $linkScript
|
||||
}
|
||||
|
||||
Write-Host "[mosaic-init] Done. Launch with: mosaic claude"
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
backup_stamp="$(date +%Y%m%d%H%M%S)"
|
||||
|
||||
# ─── Install-ordering guard opt-out (#869 Point-1 C2) ───────────────────────
|
||||
# Explicit, per-invocation CLI flag ONLY — deliberately NOT read from an
|
||||
# environment variable, so it can never sit as a silently-inherited default in
|
||||
# a shell profile or CI env. Absent (the default) => hard fail-loud path.
|
||||
allow_inactive_enforcement=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--allow-inactive-enforcement) allow_inactive_enforcement=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Tracks whether the Claude settings install-ordering guard (below) reported a
|
||||
# degraded (enforcement-not-wired) outcome, so this script's own exit status
|
||||
# reflects it even though the rest of the runtime-asset sync must still run.
|
||||
guard_degraded=0
|
||||
|
||||
copy_file_managed() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
|
||||
if [[ -L "$dst" ]]; then
|
||||
rm -f "$dst"
|
||||
fi
|
||||
|
||||
if [[ -f "$dst" ]]; then
|
||||
if cmp -s "$src" "$dst"; then
|
||||
return
|
||||
fi
|
||||
mv "$dst" "${dst}.mosaic-bak-${backup_stamp}"
|
||||
fi
|
||||
|
||||
cp "$src" "$dst"
|
||||
}
|
||||
|
||||
# ─── Install-ordering guard for settings.json (#869 Point-1 C2) ─────────────
|
||||
#
|
||||
# settings.json is where #828's enforcement hooks (PreToolUse mutator-gate.py,
|
||||
# Stop receipt-observer-client.py) get wired unconditionally. Before copying
|
||||
# it, delegate to `mosaic __link-claude-settings` (packages/mosaic/src/commands/
|
||||
# install-ordering-guard.ts) so the wiring decision is made by importing the
|
||||
# C1 activation probe (`leaseEnforcementActivatable()`) directly, rather than
|
||||
# re-implementing the capability/supervisor checks in shell. That subcommand:
|
||||
# - activatable -> writes settings.json with hooks intact, exits 0
|
||||
# - NOT activatable -> writes settings.json with hooks STRIPPED,
|
||||
# prints an actionable message, exits 1
|
||||
# - NOT activatable + opt-out -> writes settings.json with hooks intact,
|
||||
# prints a loud warning, exits 0
|
||||
# The `mosaic` CLI is expected on PATH at this point ("No executables are
|
||||
# placed on PATH — the mosaic npm CLI is the only binary", per install.sh).
|
||||
# If it is not resolvable at all, that is itself strong evidence the
|
||||
# activation half is absent, so the same fail-loud default applies via a
|
||||
# minimal python3 fallback (this repo already depends on python3 for the
|
||||
# lease broker itself).
|
||||
copy_claude_settings_guarded() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
|
||||
local guard_args=(__link-claude-settings "$src" "$dst")
|
||||
if [[ "$allow_inactive_enforcement" == "1" ]]; then
|
||||
guard_args+=(--allow-inactive-enforcement)
|
||||
fi
|
||||
|
||||
if command -v mosaic >/dev/null 2>&1; then
|
||||
if mosaic "${guard_args[@]}"; then
|
||||
return 0
|
||||
fi
|
||||
echo "[mosaic-link] Enforcement hooks were NOT wired into $dst (see message above)." >&2
|
||||
guard_degraded=1
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[mosaic-link] ERROR: 'mosaic' CLI not found on PATH — cannot confirm lease-enforcement" >&2
|
||||
echo "[mosaic-link] activation capability. enforcement requested but activation half absent —" >&2
|
||||
echo "[mosaic-link] needs a published CLI carrying launch-runtime activation + a broker" >&2
|
||||
echo "[mosaic-link] supervisor; refusing to wire a dead gate (see #869)." >&2
|
||||
|
||||
if [[ "$allow_inactive_enforcement" == "1" ]]; then
|
||||
echo "[mosaic-link] WARNING: --allow-inactive-enforcement set — wiring $dst AS-IS (with" >&2
|
||||
echo "[mosaic-link] enforcement hooks) despite being unable to confirm activation." >&2
|
||||
copy_file_managed "$src" "$dst"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$src" "$dst" <<'PYEOF'
|
||||
import json, sys
|
||||
|
||||
src, dest = sys.argv[1], sys.argv[2]
|
||||
with open(src) as f:
|
||||
data = json.load(f)
|
||||
|
||||
hooks = data.get("hooks", {})
|
||||
|
||||
pre = hooks.get("PreToolUse", [])
|
||||
hooks["PreToolUse"] = [
|
||||
t for t in pre
|
||||
if not any("mutator-gate.py" in h.get("command", "") for h in t.get("hooks", []))
|
||||
]
|
||||
if not hooks["PreToolUse"]:
|
||||
del hooks["PreToolUse"]
|
||||
|
||||
stop = hooks.get("Stop", [])
|
||||
new_stop = []
|
||||
for t in stop:
|
||||
kept = [h for h in t.get("hooks", []) if "receipt-observer-client.py" not in h.get("command", "")]
|
||||
if kept:
|
||||
t = dict(t)
|
||||
t["hooks"] = kept
|
||||
new_stop.append(t)
|
||||
if new_stop:
|
||||
hooks["Stop"] = new_stop
|
||||
elif "Stop" in hooks:
|
||||
del hooks["Stop"]
|
||||
|
||||
if hooks:
|
||||
data["hooks"] = hooks
|
||||
else:
|
||||
data.pop("hooks", None)
|
||||
|
||||
with open(dest, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
PYEOF
|
||||
else
|
||||
cp "$src" "$dst"
|
||||
fi
|
||||
guard_degraded=1
|
||||
return 0
|
||||
}
|
||||
|
||||
remove_legacy_path() {
|
||||
local p="$1"
|
||||
|
||||
if [[ -L "$p" ]]; then
|
||||
rm -f "$p"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -d "$p" ]]; then
|
||||
find "$p" -depth -type l -delete 2>/dev/null || true
|
||||
find "$p" -depth -type d -empty -delete 2>/dev/null || true
|
||||
return
|
||||
fi
|
||||
|
||||
# Remove stale symlinked files if present.
|
||||
if [[ -e "$p" && -L "$p" ]]; then
|
||||
rm -f "$p"
|
||||
fi
|
||||
}
|
||||
|
||||
# Remove compatibility symlink surfaces for migrated content.
|
||||
legacy_paths=(
|
||||
"$HOME/.claude/agent-guides"
|
||||
"$HOME/.claude/scripts/git"
|
||||
"$HOME/.claude/scripts/codex"
|
||||
"$HOME/.claude/scripts/bootstrap"
|
||||
"$HOME/.claude/scripts/cicd"
|
||||
"$HOME/.claude/scripts/portainer"
|
||||
"$HOME/.claude/scripts/debug-hook.sh"
|
||||
"$HOME/.claude/scripts/qa-hook-handler.sh"
|
||||
"$HOME/.claude/scripts/qa-hook-stdin.sh"
|
||||
"$HOME/.claude/scripts/qa-hook-wrapper.sh"
|
||||
"$HOME/.claude/scripts/qa-queue-monitor.sh"
|
||||
"$HOME/.claude/scripts/remediation-hook-handler.sh"
|
||||
"$HOME/.claude/templates"
|
||||
"$HOME/.claude/presets/domains"
|
||||
"$HOME/.claude/presets/tech-stacks"
|
||||
"$HOME/.claude/presets/workflows"
|
||||
)
|
||||
|
||||
for p in "${legacy_paths[@]}"; do
|
||||
remove_legacy_path "$p"
|
||||
done
|
||||
|
||||
# Claude-specific runtime files (settings, hooks — NOT CLAUDE.md which is now a thin pointer)
|
||||
# When MOSAIC_SKIP_CLAUDE_HOOKS=1 is set (user declined hooks in the wizard
|
||||
# preview stage), skip hooks-config.json but still copy the other runtime
|
||||
# files so Claude still gets CLAUDE.md/settings.json/context7 guidance.
|
||||
for runtime_file in \
|
||||
CLAUDE.md \
|
||||
settings.json \
|
||||
hooks-config.json \
|
||||
context7-integration.md; do
|
||||
if [[ "$runtime_file" == "hooks-config.json" ]] && [[ "${MOSAIC_SKIP_CLAUDE_HOOKS:-0}" == "1" ]]; then
|
||||
echo "[mosaic-link] Skipping hooks-config.json (user declined in wizard)"
|
||||
# An existing ~/.claude/hooks-config.json that we previously installed
|
||||
# is identified by one of:
|
||||
# 1. It's a symlink (legacy symlink-mode install)
|
||||
# 2. It contains the `mosaic-managed` marker string we embed in the
|
||||
# template (survives template updates unlike byte-equality)
|
||||
# 3. It is byte-identical to the current Mosaic template (fallback
|
||||
# for templates that pre-date the marker)
|
||||
# Anything else is user-owned and we must leave it alone.
|
||||
existing_hooks="$HOME/.claude/hooks-config.json"
|
||||
mosaic_hooks_src="$MOSAIC_HOME/runtime/claude/hooks-config.json"
|
||||
if [[ -L "$existing_hooks" ]]; then
|
||||
rm -f "$existing_hooks"
|
||||
echo "[mosaic-link] Removed previously-linked Mosaic hooks-config.json (was symlink)"
|
||||
elif [[ -f "$existing_hooks" ]]; then
|
||||
is_mosaic_managed=0
|
||||
if grep -q 'mosaic-managed' "$existing_hooks" 2>/dev/null; then
|
||||
is_mosaic_managed=1
|
||||
elif [[ -f "$mosaic_hooks_src" ]] && cmp -s "$existing_hooks" "$mosaic_hooks_src"; then
|
||||
is_mosaic_managed=1
|
||||
fi
|
||||
if [[ "$is_mosaic_managed" == "1" ]]; then
|
||||
mv "$existing_hooks" "${existing_hooks}.mosaic-bak-${backup_stamp}"
|
||||
echo "[mosaic-link] Removed previously-linked Mosaic hooks-config.json (backup at ${existing_hooks}.mosaic-bak-${backup_stamp})"
|
||||
else
|
||||
echo "[mosaic-link] Leaving existing non-Mosaic hooks-config.json in place"
|
||||
fi
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
src="$MOSAIC_HOME/runtime/claude/$runtime_file"
|
||||
[[ -f "$src" ]] || continue
|
||||
if [[ "$runtime_file" == "settings.json" ]]; then
|
||||
# Install-ordering guard (#869 Point-1 C2): gate enforcement-hook wiring
|
||||
# on confirmed activation instead of the plain copy_file_managed used for
|
||||
# every other runtime file. See copy_claude_settings_guarded() above.
|
||||
copy_claude_settings_guarded "$src" "$HOME/.claude/$runtime_file"
|
||||
continue
|
||||
fi
|
||||
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
|
||||
done
|
||||
|
||||
if [[ -d "$MOSAIC_HOME/runtime/claude/commands" ]]; then
|
||||
mkdir -p "$HOME/.claude/commands"
|
||||
for command_file in "$MOSAIC_HOME/runtime/claude/commands/"*; do
|
||||
[[ -f "$command_file" ]] || continue
|
||||
copy_file_managed "$command_file" "$HOME/.claude/commands/$(basename "$command_file")"
|
||||
done
|
||||
fi
|
||||
|
||||
# OpenCode runtime adapter (thin pointer to AGENTS.md)
|
||||
opencode_adapter="$MOSAIC_HOME/runtime/opencode/AGENTS.md"
|
||||
if [[ -f "$opencode_adapter" ]]; then
|
||||
copy_file_managed "$opencode_adapter" "$HOME/.config/opencode/AGENTS.md"
|
||||
fi
|
||||
|
||||
# Codex runtime adapter (thin pointer to AGENTS.md)
|
||||
codex_adapter="$MOSAIC_HOME/runtime/codex/instructions.md"
|
||||
if [[ -f "$codex_adapter" ]]; then
|
||||
mkdir -p "$HOME/.codex"
|
||||
copy_file_managed "$codex_adapter" "$HOME/.codex/instructions.md"
|
||||
fi
|
||||
|
||||
# Pi runtime settings (MCP + skills paths)
|
||||
pi_settings_dir="$HOME/.pi/agent"
|
||||
pi_settings_file="$pi_settings_dir/settings.json"
|
||||
mkdir -p "$pi_settings_dir"
|
||||
|
||||
if [[ ! -f "$pi_settings_file" ]]; then
|
||||
echo '{}' > "$pi_settings_file"
|
||||
fi
|
||||
|
||||
# Ensure Pi settings.json has Mosaic skills paths
|
||||
mosaic_skills_path="$MOSAIC_HOME/skills"
|
||||
mosaic_local_path="$MOSAIC_HOME/skills-local"
|
||||
if ! grep -q "$mosaic_skills_path" "$pi_settings_file" 2>/dev/null; then
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 -c "
|
||||
import json
|
||||
with open('$pi_settings_file', 'r') as f:
|
||||
data = json.load(f)
|
||||
skills = data.get('skills', [])
|
||||
if not isinstance(skills, list):
|
||||
skills = []
|
||||
for p in ['$mosaic_skills_path', '$mosaic_local_path']:
|
||||
if p not in skills:
|
||||
skills.append(p)
|
||||
data['skills'] = skills
|
||||
with open('$pi_settings_file', 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write('\\n')
|
||||
" 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
|
||||
# Pi extension is loaded via --extension flag in the mosaic launcher.
|
||||
# Do NOT copy into ~/.pi/agent/extensions/ — that causes duplicate loading.
|
||||
|
||||
if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-ensure-sequential-thinking" ]]; then
|
||||
"$MOSAIC_HOME/tools/_scripts/mosaic-ensure-sequential-thinking"
|
||||
fi
|
||||
|
||||
echo "[mosaic-link] Runtime assets synced (non-symlink mode)"
|
||||
echo "[mosaic-link] Canonical source: $MOSAIC_HOME"
|
||||
|
||||
# Propagate the install-ordering guard's outcome (#869 Point-1 C2): every
|
||||
# other runtime asset above is best-effort/non-fatal, but a degraded
|
||||
# (enforcement-not-wired) settings.json must make THIS script's own exit
|
||||
# status non-zero so callers (framework/install.sh, finalize.ts) can surface
|
||||
# it — never silently.
|
||||
if [[ "$guard_degraded" == "1" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,110 @@
|
||||
# mosaic-link-runtime-assets.ps1
|
||||
# Syncs Mosaic runtime config files into agent runtime directories.
|
||||
# PowerShell equivalent of mosaic-link-runtime-assets (bash).
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$MosaicHome = if ($env:MOSAIC_HOME) { $env:MOSAIC_HOME } else { Join-Path $env:USERPROFILE ".config\mosaic" }
|
||||
$BackupStamp = Get-Date -Format "yyyyMMddHHmmss"
|
||||
|
||||
function Copy-FileManaged {
|
||||
param([string]$Src, [string]$Dst)
|
||||
|
||||
$parent = Split-Path $Dst -Parent
|
||||
if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
|
||||
|
||||
# Remove existing symlink/junction
|
||||
$item = Get-Item $Dst -Force -ErrorAction SilentlyContinue
|
||||
if ($item -and $item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
|
||||
Remove-Item $Dst -Force
|
||||
}
|
||||
|
||||
if (Test-Path $Dst) {
|
||||
$srcHash = (Get-FileHash $Src -Algorithm SHA256).Hash
|
||||
$dstHash = (Get-FileHash $Dst -Algorithm SHA256).Hash
|
||||
if ($srcHash -eq $dstHash) { return }
|
||||
Rename-Item $Dst "$Dst.mosaic-bak-$BackupStamp"
|
||||
}
|
||||
|
||||
Copy-Item $Src $Dst -Force
|
||||
}
|
||||
|
||||
function Remove-LegacyPath {
|
||||
param([string]$Path)
|
||||
|
||||
if (-not (Test-Path $Path)) { return }
|
||||
|
||||
$item = Get-Item $Path -Force -ErrorAction SilentlyContinue
|
||||
if ($item -and $item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
|
||||
Remove-Item $Path -Force
|
||||
return
|
||||
}
|
||||
|
||||
if (Test-Path $Path -PathType Container) {
|
||||
# Remove symlinks/junctions inside, then empty dirs
|
||||
Get-ChildItem $Path -Recurse -Force | Where-Object {
|
||||
$_.Attributes -band [System.IO.FileAttributes]::ReparsePoint
|
||||
} | Remove-Item -Force
|
||||
|
||||
Get-ChildItem $Path -Recurse -Directory -Force |
|
||||
Sort-Object { $_.FullName.Length } -Descending |
|
||||
Where-Object { (Get-ChildItem $_.FullName -Force | Measure-Object).Count -eq 0 } |
|
||||
Remove-Item -Force
|
||||
}
|
||||
}
|
||||
|
||||
# Remove legacy compatibility paths
|
||||
$legacyPaths = @(
|
||||
(Join-Path $env:USERPROFILE ".claude\agent-guides"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\git"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\codex"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\bootstrap"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\cicd"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\portainer"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\debug-hook.sh"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\qa-hook-handler.sh"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\qa-hook-stdin.sh"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\qa-hook-wrapper.sh"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\qa-queue-monitor.sh"),
|
||||
(Join-Path $env:USERPROFILE ".claude\scripts\remediation-hook-handler.sh"),
|
||||
(Join-Path $env:USERPROFILE ".claude\templates"),
|
||||
(Join-Path $env:USERPROFILE ".claude\presets\domains"),
|
||||
(Join-Path $env:USERPROFILE ".claude\presets\tech-stacks"),
|
||||
(Join-Path $env:USERPROFILE ".claude\presets\workflows"),
|
||||
)
|
||||
|
||||
foreach ($p in $legacyPaths) {
|
||||
Remove-LegacyPath $p
|
||||
}
|
||||
|
||||
# Claude-specific runtime files (settings, hooks — CLAUDE.md is now a thin pointer)
|
||||
$runtimeFiles = @("CLAUDE.md", "settings.json", "hooks-config.json", "context7-integration.md")
|
||||
foreach ($rf in $runtimeFiles) {
|
||||
$src = Join-Path $MosaicHome "runtime\claude\$rf"
|
||||
if (-not (Test-Path $src)) { continue }
|
||||
$dst = Join-Path $env:USERPROFILE ".claude\$rf"
|
||||
Copy-FileManaged $src $dst
|
||||
}
|
||||
|
||||
# OpenCode runtime adapter
|
||||
$opencodeSrc = Join-Path $MosaicHome "runtime\opencode\AGENTS.md"
|
||||
if (Test-Path $opencodeSrc) {
|
||||
$opencodeDst = Join-Path $env:USERPROFILE ".config\opencode\AGENTS.md"
|
||||
Copy-FileManaged $opencodeSrc $opencodeDst
|
||||
}
|
||||
|
||||
# Codex runtime adapter
|
||||
$codexSrc = Join-Path $MosaicHome "runtime\codex\instructions.md"
|
||||
if (Test-Path $codexSrc) {
|
||||
$codexDir = Join-Path $env:USERPROFILE ".codex"
|
||||
if (-not (Test-Path $codexDir)) { New-Item -ItemType Directory -Path $codexDir -Force | Out-Null }
|
||||
$codexDst = Join-Path $codexDir "instructions.md"
|
||||
Copy-FileManaged $codexSrc $codexDst
|
||||
}
|
||||
|
||||
$seqScript = Join-Path $MosaicHome "bin\mosaic-ensure-sequential-thinking.ps1"
|
||||
if (Test-Path $seqScript) {
|
||||
& $seqScript
|
||||
}
|
||||
|
||||
Write-Host "[mosaic-link] Runtime assets synced (non-symlink mode)"
|
||||
Write-Host "[mosaic-link] Canonical source: $MosaicHome"
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -x "scripts/agent/log-limitation.sh" ]]; then
|
||||
exec bash scripts/agent/log-limitation.sh "$@"
|
||||
fi
|
||||
|
||||
echo "[mosaic] Missing scripts/agent/log-limitation.sh in $(pwd)" >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
APPLY=0
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") [--apply]
|
||||
|
||||
Migrate runtime-local skill directories (e.g. ~/.claude/skills/<name>) to Mosaic-managed
|
||||
skills by replacing local directories with symlinks to ~/.config/mosaic/skills-local.
|
||||
|
||||
Default mode is dry-run.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--apply)
|
||||
APPLY=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
skill_roots=(
|
||||
"$HOME/.claude/skills"
|
||||
"$HOME/.codex/skills"
|
||||
"$HOME/.config/opencode/skills"
|
||||
"$HOME/.pi/agent/skills"
|
||||
)
|
||||
|
||||
if [[ ! -d "$MOSAIC_HOME/skills-local" ]]; then
|
||||
echo "[mosaic-local-skills] Missing local skills dir: $MOSAIC_HOME/skills-local" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
count=0
|
||||
|
||||
while IFS= read -r -d '' local_skill; do
|
||||
name="$(basename "$local_skill")"
|
||||
src="$MOSAIC_HOME/skills-local/$name"
|
||||
[[ -d "$src" ]] || continue
|
||||
|
||||
for root in "${skill_roots[@]}"; do
|
||||
[[ -d "$root" ]] || continue
|
||||
target="$root/$name"
|
||||
|
||||
# Already linked correctly.
|
||||
if [[ -L "$target" ]]; then
|
||||
target_real="$(readlink -f "$target" 2>/dev/null || true)"
|
||||
src_real="$(readlink -f "$src" 2>/dev/null || true)"
|
||||
if [[ -n "$target_real" && -n "$src_real" && "$target_real" == "$src_real" ]]; then
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Only migrate local directories containing SKILL.md
|
||||
if [[ -d "$target" && -f "$target/SKILL.md" && ! -L "$target" ]]; then
|
||||
count=$((count + 1))
|
||||
if [[ $APPLY -eq 1 ]]; then
|
||||
stamp="$(date +%Y%m%d%H%M%S)"
|
||||
mv "$target" "${target}.mosaic-bak-${stamp}"
|
||||
ln -s "$src" "$target"
|
||||
echo "[mosaic-local-skills] migrated: $target -> $src"
|
||||
else
|
||||
echo "[mosaic-local-skills] would migrate: $target -> $src"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done < <(find "$MOSAIC_HOME/skills-local" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print0)
|
||||
|
||||
if [[ $APPLY -eq 1 ]]; then
|
||||
echo "[mosaic-local-skills] complete: migrated=$count"
|
||||
else
|
||||
echo "[mosaic-local-skills] dry-run: migratable=$count"
|
||||
echo "[mosaic-local-skills] re-run with --apply to migrate"
|
||||
fi
|
||||
@@ -0,0 +1,90 @@
|
||||
# mosaic-migrate-local-skills.ps1
|
||||
# Migrates runtime-local skill directories to Mosaic-managed junctions.
|
||||
# Uses directory junctions (no elevation required) with fallback to copies.
|
||||
# PowerShell equivalent of mosaic-migrate-local-skills (bash).
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
param(
|
||||
[switch]$Apply,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$MosaicHome = if ($env:MOSAIC_HOME) { $env:MOSAIC_HOME } else { Join-Path $env:USERPROFILE ".config\mosaic" }
|
||||
$LocalSkillsDir = Join-Path $MosaicHome "skills-local"
|
||||
|
||||
if ($Help) {
|
||||
Write-Host @"
|
||||
Usage: mosaic-migrate-local-skills.ps1 [-Apply] [-Help]
|
||||
|
||||
Migrate runtime-local skill directories (e.g. ~/.claude/skills/<name>) to
|
||||
Mosaic-managed skills by replacing local directories with junctions to
|
||||
~/.config/mosaic/skills-local.
|
||||
|
||||
Default mode is dry-run.
|
||||
"@
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Test-Path $LocalSkillsDir)) {
|
||||
Write-Host "[mosaic-local-skills] Missing local skills dir: $LocalSkillsDir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$skillRoots = @(
|
||||
(Join-Path $env:USERPROFILE ".claude\skills"),
|
||||
(Join-Path $env:USERPROFILE ".codex\skills"),
|
||||
(Join-Path $env:USERPROFILE ".config\opencode\skills")
|
||||
)
|
||||
|
||||
$count = 0
|
||||
|
||||
Get-ChildItem $LocalSkillsDir -Directory | ForEach-Object {
|
||||
$name = $_.Name
|
||||
$src = $_.FullName
|
||||
|
||||
foreach ($root in $skillRoots) {
|
||||
if (-not (Test-Path $root)) { continue }
|
||||
$target = Join-Path $root $name
|
||||
|
||||
# Already a junction/symlink — check if it points to the right place
|
||||
$existing = Get-Item $target -Force -ErrorAction SilentlyContinue
|
||||
if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
||||
$currentTarget = $existing.Target
|
||||
if ($currentTarget -and ($currentTarget -eq $src -or (Resolve-Path $currentTarget -ErrorAction SilentlyContinue).Path -eq (Resolve-Path $src -ErrorAction SilentlyContinue).Path)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
# Only migrate local directories containing SKILL.md
|
||||
if ((Test-Path $target -PathType Container) -and
|
||||
(Test-Path (Join-Path $target "SKILL.md")) -and
|
||||
-not ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint))) {
|
||||
|
||||
$count++
|
||||
if ($Apply) {
|
||||
$stamp = Get-Date -Format "yyyyMMddHHmmss"
|
||||
Rename-Item $target "$target.mosaic-bak-$stamp"
|
||||
try {
|
||||
New-Item -ItemType Junction -Path $target -Target $src -ErrorAction Stop | Out-Null
|
||||
Write-Host "[mosaic-local-skills] migrated: $target -> $src"
|
||||
}
|
||||
catch {
|
||||
Write-Host "[mosaic-local-skills] Junction failed for $name, falling back to copy"
|
||||
Copy-Item $src $target -Recurse -Force
|
||||
Write-Host "[mosaic-local-skills] copied: $target <- $src"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "[mosaic-local-skills] would migrate: $target -> $src"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($Apply) {
|
||||
Write-Host "[mosaic-local-skills] complete: migrated=$count"
|
||||
}
|
||||
else {
|
||||
Write-Host "[mosaic-local-skills] dry-run: migratable=$count"
|
||||
Write-Host "[mosaic-local-skills] re-run with -Apply to migrate"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
sync_cmd="$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-sync-tasks"
|
||||
run_cmd="$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-run"
|
||||
|
||||
do_sync=1
|
||||
poll_sec=15
|
||||
extra_args=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-sync)
|
||||
do_sync=0
|
||||
shift
|
||||
;;
|
||||
--poll-sec)
|
||||
poll_sec="${2:-15}"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
extra_args+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $do_sync -eq 1 ]]; then
|
||||
"$sync_cmd" --apply
|
||||
fi
|
||||
|
||||
exec "$run_cmd" --until-drained --poll-sec "$poll_sec" "${extra_args[@]}"
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
BRIDGE="$MOSAIC_HOME/tools/orchestrator-matrix/transport/matrix_transport.py"
|
||||
|
||||
if [[ ! -f "$BRIDGE" ]]; then
|
||||
echo "[mosaic-orch-matrix] missing transport bridge: $BRIDGE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec python3 "$BRIDGE" --repo "$(pwd)" --mode consume "$@"
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
|
||||
consume="$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-matrix-consume"
|
||||
run="$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-run"
|
||||
publish="$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-matrix-publish"
|
||||
|
||||
for cmd in "$consume" "$run" "$publish"; do
|
||||
if [[ ! -x "$cmd" ]]; then
|
||||
echo "[mosaic-orch-cycle] missing executable: $cmd" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
"$consume"
|
||||
"$run" --once "$@"
|
||||
"$publish"
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
BRIDGE="$MOSAIC_HOME/tools/orchestrator-matrix/transport/matrix_transport.py"
|
||||
|
||||
if [[ ! -f "$BRIDGE" ]]; then
|
||||
echo "[mosaic-orch-matrix] missing transport bridge: $BRIDGE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec python3 "$BRIDGE" --repo "$(pwd)" --mode publish "$@"
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
CTRL="$MOSAIC_HOME/tools/orchestrator-matrix/controller/mosaic_orchestrator.py"
|
||||
|
||||
if [[ ! -f "$CTRL" ]]; then
|
||||
echo "[mosaic-orchestrator] missing controller: $CTRL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec python3 "$CTRL" --repo "$(pwd)" "$@"
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
SYNC="$MOSAIC_HOME/tools/orchestrator-matrix/controller/tasks_md_sync.py"
|
||||
|
||||
if [[ ! -f "$SYNC" ]]; then
|
||||
echo "[mosaic-orchestrator-sync] missing sync script: $SYNC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec python3 "$SYNC" --repo "$(pwd)" "$@"
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
PROJECTS_FILE="$MOSAIC_HOME/projects.txt"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") <command> [options]
|
||||
|
||||
Commands:
|
||||
init
|
||||
Create projects registry file at ~/.config/mosaic/projects.txt
|
||||
|
||||
add <repo-path> [repo-path...]
|
||||
Add one or more repos to the registry
|
||||
|
||||
remove <repo-path> [repo-path...]
|
||||
Remove one or more repos from the registry
|
||||
|
||||
list
|
||||
Show registered repos
|
||||
|
||||
bootstrap [--all|repo-path...] [--force] [--quality-template <name>]
|
||||
Bootstrap registered repos or explicit repo paths
|
||||
|
||||
orchestrate <drain|start|status|stop> [--all|repo-path...] [--poll-sec N] [--no-sync] [--worker-cmd "cmd"]
|
||||
Run orchestrator actions across repos from one command
|
||||
|
||||
Examples:
|
||||
mosaic-projects init
|
||||
mosaic-projects add ~/src/syncagent ~/src/inventory-stickers
|
||||
mosaic-projects bootstrap --all
|
||||
mosaic-projects orchestrate drain --all --worker-cmd "codex -p"
|
||||
mosaic-projects orchestrate start ~/src/syncagent --worker-cmd "opencode -p"
|
||||
USAGE
|
||||
}
|
||||
|
||||
ensure_registry() {
|
||||
mkdir -p "$MOSAIC_HOME"
|
||||
if [[ ! -f "$PROJECTS_FILE" ]]; then
|
||||
cat > "$PROJECTS_FILE" <<EOF
|
||||
# Mosaic managed projects (one absolute path per line)
|
||||
# Lines starting with # are ignored.
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
norm_path() {
|
||||
local p="$1"
|
||||
if [[ -d "$p" ]]; then
|
||||
(cd "$p" && pwd)
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
read_registry() {
|
||||
ensure_registry
|
||||
grep -vE '^\s*#|^\s*$' "$PROJECTS_FILE" | while read -r p; do
|
||||
[[ -d "$p" ]] && echo "$p"
|
||||
done
|
||||
}
|
||||
|
||||
add_repo() {
|
||||
local p="$1"
|
||||
ensure_registry
|
||||
local np
|
||||
np="$(norm_path "$p")" || { echo "[mosaic-projects] skip missing dir: $p" >&2; return 1; }
|
||||
if grep -Fxq "$np" "$PROJECTS_FILE"; then
|
||||
echo "[mosaic-projects] already registered: $np"
|
||||
return 0
|
||||
fi
|
||||
echo "$np" >> "$PROJECTS_FILE"
|
||||
echo "[mosaic-projects] added: $np"
|
||||
}
|
||||
|
||||
remove_repo() {
|
||||
local p="$1"
|
||||
ensure_registry
|
||||
local np
|
||||
np="$(norm_path "$p" 2>/dev/null || echo "$p")"
|
||||
tmp="$(mktemp)"
|
||||
grep -vFx "$np" "$PROJECTS_FILE" > "$tmp" || true
|
||||
mv "$tmp" "$PROJECTS_FILE"
|
||||
echo "[mosaic-projects] removed: $np"
|
||||
}
|
||||
|
||||
resolve_targets() {
|
||||
local use_all="$1"
|
||||
shift
|
||||
if [[ "$use_all" == "1" ]]; then
|
||||
read_registry
|
||||
return 0
|
||||
fi
|
||||
if [[ $# -gt 0 ]]; then
|
||||
for p in "$@"; do
|
||||
norm_path "$p" || { echo "[mosaic-projects] missing target: $p" >&2; exit 1; }
|
||||
done
|
||||
return 0
|
||||
fi
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git rev-parse --show-toplevel
|
||||
return 0
|
||||
fi
|
||||
echo "[mosaic-projects] no targets provided. Use --all or pass repo paths." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cmd="${1:-}"
|
||||
if [[ -z "$cmd" ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
shift || true
|
||||
|
||||
case "$cmd" in
|
||||
init)
|
||||
ensure_registry
|
||||
echo "[mosaic-projects] registry ready: $PROJECTS_FILE"
|
||||
;;
|
||||
add)
|
||||
[[ $# -gt 0 ]] || { echo "[mosaic-projects] add requires repo path(s)" >&2; exit 1; }
|
||||
for p in "$@"; do add_repo "$p"; done
|
||||
;;
|
||||
remove)
|
||||
[[ $# -gt 0 ]] || { echo "[mosaic-projects] remove requires repo path(s)" >&2; exit 1; }
|
||||
for p in "$@"; do remove_repo "$p"; done
|
||||
;;
|
||||
list)
|
||||
read_registry
|
||||
;;
|
||||
bootstrap)
|
||||
use_all=0
|
||||
force=0
|
||||
quality_template=""
|
||||
targets=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--all) use_all=1; shift ;;
|
||||
--force) force=1; shift ;;
|
||||
--quality-template) quality_template="${2:-}"; shift 2 ;;
|
||||
*) targets+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
mapfile -t repos < <(resolve_targets "$use_all" "${targets[@]}")
|
||||
[[ ${#repos[@]} -gt 0 ]] || { echo "[mosaic-projects] no repos resolved"; exit 1; }
|
||||
for repo in "${repos[@]}"; do
|
||||
args=()
|
||||
[[ $force -eq 1 ]] && args+=(--force)
|
||||
[[ -n "$quality_template" ]] && args+=(--quality-template "$quality_template")
|
||||
args+=("$repo")
|
||||
echo "[mosaic-projects] bootstrap: $repo"
|
||||
"$MOSAIC_HOME/tools/_scripts/mosaic-bootstrap-repo" "${args[@]}"
|
||||
add_repo "$repo" || true
|
||||
done
|
||||
;;
|
||||
orchestrate)
|
||||
action="${1:-}"
|
||||
[[ -n "$action" ]] || { echo "[mosaic-projects] orchestrate requires action: drain|start|status|stop" >&2; exit 1; }
|
||||
shift || true
|
||||
use_all=0
|
||||
poll_sec=15
|
||||
no_sync=0
|
||||
worker_cmd=""
|
||||
targets=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--all) use_all=1; shift ;;
|
||||
--poll-sec) poll_sec="${2:-15}"; shift 2 ;;
|
||||
--no-sync) no_sync=1; shift ;;
|
||||
--worker-cmd) worker_cmd="${2:-}"; shift 2 ;;
|
||||
*) targets+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
mapfile -t repos < <(resolve_targets "$use_all" "${targets[@]}")
|
||||
[[ ${#repos[@]} -gt 0 ]] || { echo "[mosaic-projects] no repos resolved"; exit 1; }
|
||||
|
||||
for repo in "${repos[@]}"; do
|
||||
echo "[mosaic-projects] orchestrate:$action -> $repo"
|
||||
(
|
||||
cd "$repo"
|
||||
if [[ -n "$worker_cmd" ]]; then
|
||||
export MOSAIC_WORKER_EXEC="$worker_cmd"
|
||||
fi
|
||||
if [[ -x "scripts/agent/orchestrator-daemon.sh" ]]; then
|
||||
args=()
|
||||
[[ "$action" == "start" || "$action" == "drain" ]] && args+=(--poll-sec "$poll_sec")
|
||||
[[ $no_sync -eq 1 ]] && args+=(--no-sync)
|
||||
bash scripts/agent/orchestrator-daemon.sh "$action" "${args[@]}"
|
||||
else
|
||||
case "$action" in
|
||||
drain)
|
||||
args=(--poll-sec "$poll_sec")
|
||||
[[ $no_sync -eq 1 ]] && args+=(--no-sync)
|
||||
"$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-drain" "${args[@]}"
|
||||
;;
|
||||
status)
|
||||
echo "[mosaic-projects] no daemon script in repo; run from bootstrapped repo or re-bootstrap"
|
||||
;;
|
||||
start|stop)
|
||||
echo "[mosaic-projects] action '$action' requires scripts/agent/orchestrator-daemon.sh (run bootstrap first)" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "[mosaic-projects] unsupported action: $action" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
)
|
||||
done
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
RUNTIME="claude"
|
||||
APPLY=0
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") [options]
|
||||
|
||||
Remove legacy runtime files that were preserved as *.mosaic-bak-* after Mosaic linking.
|
||||
Only removes backups when the active file is a symlink to ~/.config/mosaic.
|
||||
|
||||
Options:
|
||||
--runtime <name> Runtime to prune (default: claude)
|
||||
--apply Perform deletions (default: dry-run)
|
||||
-h, --help Show help
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--runtime)
|
||||
[[ $# -lt 2 ]] && { echo "Missing value for --runtime" >&2; exit 1; }
|
||||
RUNTIME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--apply)
|
||||
APPLY=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$RUNTIME" in
|
||||
claude)
|
||||
TARGET_ROOT="$HOME/.claude"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported runtime: $RUNTIME" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ ! -d "$TARGET_ROOT" ]]; then
|
||||
echo "[mosaic-prune] Runtime directory not found: $TARGET_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mosaic_real="$(readlink -f "$MOSAIC_HOME")"
|
||||
count_candidates=0
|
||||
count_deletable=0
|
||||
|
||||
while IFS= read -r -d '' bak; do
|
||||
count_candidates=$((count_candidates + 1))
|
||||
|
||||
base="${bak%%.mosaic-bak-*}"
|
||||
if [[ ! -L "$base" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
base_real="$(readlink -f "$base" 2>/dev/null || true)"
|
||||
if [[ -z "$base_real" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$base_real" != "$mosaic_real"/* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
count_deletable=$((count_deletable + 1))
|
||||
if [[ $APPLY -eq 1 ]]; then
|
||||
rm -rf "$bak"
|
||||
echo "[mosaic-prune] deleted: $bak"
|
||||
else
|
||||
echo "[mosaic-prune] would delete: $bak"
|
||||
fi
|
||||
done < <(find "$TARGET_ROOT" \( -type f -o -type d \) -name '*.mosaic-bak-*' -print0)
|
||||
|
||||
if [[ $APPLY -eq 1 ]]; then
|
||||
echo "[mosaic-prune] complete: deleted=$count_deletable candidates=$count_candidates runtime=$RUNTIME"
|
||||
else
|
||||
echo "[mosaic-prune] dry-run: deletable=$count_deletable candidates=$count_candidates runtime=$RUNTIME"
|
||||
echo "[mosaic-prune] re-run with --apply to delete"
|
||||
fi
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
TARGET_DIR="$(pwd)"
|
||||
TEMPLATE=""
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") --template <name> [--target <dir>]
|
||||
|
||||
Apply Mosaic quality tools templates into a project.
|
||||
|
||||
Templates:
|
||||
typescript-node
|
||||
typescript-nextjs
|
||||
monorepo
|
||||
|
||||
Examples:
|
||||
$(basename "$0") --template typescript-node --target ~/src/my-project
|
||||
$(basename "$0") --template monorepo
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--template)
|
||||
TEMPLATE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--target)
|
||||
TARGET_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$TEMPLATE" ]]; then
|
||||
echo "[mosaic-quality] Missing required --template" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$TARGET_DIR" ]]; then
|
||||
echo "[mosaic-quality] Target directory does not exist: $TARGET_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT="$MOSAIC_HOME/tools/quality/scripts/install.sh"
|
||||
if [[ ! -x "$SCRIPT" ]]; then
|
||||
echo "[mosaic-quality] Missing install script: $SCRIPT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[mosaic-quality] Applying template '$TEMPLATE' to $TARGET_DIR"
|
||||
"$SCRIPT" --template "$TEMPLATE" --target "$TARGET_DIR"
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
TARGET_DIR="$(pwd)"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") [--target <dir>]
|
||||
|
||||
Run quality-rails verification checks inside a target repository.
|
||||
|
||||
Examples:
|
||||
$(basename "$0")
|
||||
$(basename "$0") --target ~/src/my-project
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--target)
|
||||
TARGET_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -d "$TARGET_DIR" ]]; then
|
||||
echo "[mosaic-quality] Target directory does not exist: $TARGET_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT="$MOSAIC_HOME/tools/quality/scripts/verify.sh"
|
||||
if [[ ! -x "$SCRIPT" ]]; then
|
||||
echo "[mosaic-quality] Missing verify script: $SCRIPT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[mosaic-quality] Running verification in $TARGET_DIR"
|
||||
(
|
||||
cd "$TARGET_DIR"
|
||||
"$SCRIPT"
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# mosaic-release-upgrade — Upgrade installed Mosaic framework release.
|
||||
#
|
||||
# This re-runs the remote installer with explicit install mode controls.
|
||||
# Default behavior is safe/idempotent (keep SOUL.md + memory).
|
||||
#
|
||||
# Usage:
|
||||
# mosaic-release-upgrade
|
||||
# mosaic-release-upgrade --ref main --keep
|
||||
# mosaic-release-upgrade --ref v0.2.0 --overwrite --yes
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
REMOTE_SCRIPT_URL="${MOSAIC_REMOTE_INSTALL_URL:-https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh}"
|
||||
BOOTSTRAP_REF="${MOSAIC_BOOTSTRAP_REF:-main}"
|
||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-keep}" # keep|overwrite
|
||||
YES=false
|
||||
DRY_RUN=false
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") [options]
|
||||
|
||||
Upgrade the installed Mosaic framework release.
|
||||
|
||||
Options:
|
||||
--ref <name> Bootstrap archive ref (branch/tag/commit). Default: main
|
||||
--keep Keep local files (SOUL.md, memory/) during upgrade (default)
|
||||
--overwrite Overwrite target install directory contents
|
||||
-y, --yes Skip confirmation prompt
|
||||
--dry-run Show actions without executing
|
||||
-h, --help Show this help
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ref)
|
||||
[[ $# -lt 2 ]] && { echo "Missing value for --ref" >&2; exit 1; }
|
||||
BOOTSTRAP_REF="$2"
|
||||
shift 2
|
||||
;;
|
||||
--keep)
|
||||
INSTALL_MODE="keep"
|
||||
shift
|
||||
;;
|
||||
--overwrite)
|
||||
INSTALL_MODE="overwrite"
|
||||
shift
|
||||
;;
|
||||
-y|--yes)
|
||||
YES=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$INSTALL_MODE" in
|
||||
keep|overwrite) ;;
|
||||
*)
|
||||
echo "[mosaic-release-upgrade] Invalid install mode: $INSTALL_MODE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
current_version="unknown"
|
||||
if [[ -x "$MOSAIC_HOME/bin/mosaic" ]]; then
|
||||
current_version="$("$MOSAIC_HOME/bin/mosaic" --version 2>/dev/null | awk '{print $2}' || true)"
|
||||
[[ -n "$current_version" ]] || current_version="unknown"
|
||||
fi
|
||||
|
||||
echo "[mosaic-release-upgrade] Current version: $current_version"
|
||||
echo "[mosaic-release-upgrade] Target ref: $BOOTSTRAP_REF"
|
||||
echo "[mosaic-release-upgrade] Install mode: $INSTALL_MODE"
|
||||
echo "[mosaic-release-upgrade] Installer URL: $REMOTE_SCRIPT_URL"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo "[mosaic-release-upgrade] Dry run: no changes applied."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$YES" != "true" && -t 0 ]]; then
|
||||
printf "Proceed with Mosaic release upgrade? [y/N]: "
|
||||
read -r confirm
|
||||
case "${confirm:-n}" in
|
||||
y|Y|yes|YES) ;;
|
||||
*)
|
||||
echo "[mosaic-release-upgrade] Aborted."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -sL "$REMOTE_SCRIPT_URL" | \
|
||||
MOSAIC_BOOTSTRAP_REF="$BOOTSTRAP_REF" \
|
||||
MOSAIC_INSTALL_MODE="$INSTALL_MODE" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
sh
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO- "$REMOTE_SCRIPT_URL" | \
|
||||
MOSAIC_BOOTSTRAP_REF="$BOOTSTRAP_REF" \
|
||||
MOSAIC_INSTALL_MODE="$INSTALL_MODE" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
sh
|
||||
else
|
||||
echo "[mosaic-release-upgrade] ERROR: curl or wget required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# mosaic-release-upgrade.ps1 — Upgrade installed Mosaic framework release (Windows)
|
||||
#
|
||||
# Usage:
|
||||
# mosaic-release-upgrade.ps1
|
||||
# mosaic-release-upgrade.ps1 -Ref main -Keep
|
||||
# mosaic-release-upgrade.ps1 -Ref v0.2.0 -Overwrite -Yes
|
||||
#
|
||||
param(
|
||||
[string]$Ref = $(if ($env:MOSAIC_BOOTSTRAP_REF) { $env:MOSAIC_BOOTSTRAP_REF } else { "main" }),
|
||||
[switch]$Keep,
|
||||
[switch]$Overwrite,
|
||||
[switch]$Yes,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$MosaicHome = if ($env:MOSAIC_HOME) { $env:MOSAIC_HOME } else { Join-Path $env:USERPROFILE ".config\mosaic" }
|
||||
$RemoteInstallerUrl = if ($env:MOSAIC_REMOTE_INSTALL_URL) {
|
||||
$env:MOSAIC_REMOTE_INSTALL_URL
|
||||
} else {
|
||||
"https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh"
|
||||
}
|
||||
|
||||
$installMode = if ($Overwrite) { "overwrite" } elseif ($Keep) { "keep" } elseif ($env:MOSAIC_INSTALL_MODE) { $env:MOSAIC_INSTALL_MODE } else { "keep" }
|
||||
if ($installMode -notin @("keep", "overwrite")) {
|
||||
Write-Host "[mosaic-release-upgrade] Invalid install mode: $installMode" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$currentVersion = "unknown"
|
||||
$mosaicCmd = Join-Path $MosaicHome "bin\mosaic.ps1"
|
||||
if (Test-Path $mosaicCmd) {
|
||||
try {
|
||||
$currentVersion = (& $mosaicCmd --version) -replace '^mosaic\s+', ''
|
||||
}
|
||||
catch {
|
||||
$currentVersion = "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "[mosaic-release-upgrade] Current version: $currentVersion"
|
||||
Write-Host "[mosaic-release-upgrade] Target ref: $Ref"
|
||||
Write-Host "[mosaic-release-upgrade] Install mode: $installMode"
|
||||
Write-Host "[mosaic-release-upgrade] Installer URL: $RemoteInstallerUrl"
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host "[mosaic-release-upgrade] Dry run: no changes applied."
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $Yes) {
|
||||
$confirmation = Read-Host "Proceed with Mosaic release upgrade? [y/N]"
|
||||
if ($confirmation -notin @("y", "Y", "yes", "YES")) {
|
||||
Write-Host "[mosaic-release-upgrade] Aborted."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
$env:MOSAIC_BOOTSTRAP_REF = $Ref
|
||||
$env:MOSAIC_INSTALL_MODE = $installMode
|
||||
$env:MOSAIC_HOME = $MosaicHome
|
||||
|
||||
Invoke-RestMethod -Uri $RemoteInstallerUrl | Invoke-Expression
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -x "scripts/agent/session-end.sh" ]]; then
|
||||
exec bash scripts/agent/session-end.sh "$@"
|
||||
fi
|
||||
|
||||
echo "[mosaic] Missing scripts/agent/session-end.sh in $(pwd)" >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -x "scripts/agent/session-start.sh" ]]; then
|
||||
exec bash scripts/agent/session-start.sh
|
||||
fi
|
||||
|
||||
echo "[mosaic] Missing scripts/agent/session-start.sh in $(pwd)" >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Link the INSTALLED canonical skills into runtime skill directories.
|
||||
#
|
||||
# The canonical skills ship inside the framework package itself
|
||||
# (packages/mosaic/framework/skills in the monorepo) and are installed into
|
||||
# $MOSAIC_HOME/skills by the framework installer — there is no second
|
||||
# repository to clone. This script only maintains the runtime symlinks.
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
MOSAIC_SKILLS_DIR="$MOSAIC_HOME/skills"
|
||||
MOSAIC_LOCAL_SKILLS_DIR="$MOSAIC_HOME/skills-local"
|
||||
|
||||
# Colon-separated list of skill names to install. When set, only these skills
|
||||
# are linked into runtime skill directories. Empty/unset = link all skills
|
||||
# (the legacy "mosaic sync" full-catalog behavior).
|
||||
MOSAIC_INSTALL_SKILLS="${MOSAIC_INSTALL_SKILLS:-}"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") [options]
|
||||
|
||||
Link installed skills from ~/.config/mosaic/{skills,skills-local} into runtime
|
||||
skill directories. Canonical skills arrive with the framework installer; this
|
||||
script never clones or pulls a second repository.
|
||||
|
||||
Options:
|
||||
--link-only Accepted for compatibility; linking is now the whole job
|
||||
--no-link Do nothing (kept for compatibility)
|
||||
-h, --help Show help
|
||||
|
||||
Env:
|
||||
MOSAIC_HOME Default: ~/.config/mosaic
|
||||
MOSAIC_INSTALL_SKILLS Colon-separated list of skills to link (default: all)
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--link-only)
|
||||
# Compat no-op: fetching a skills repo no longer exists.
|
||||
shift
|
||||
;;
|
||||
--no-link)
|
||||
echo "[mosaic-skills] Nothing to do (--no-link; canonical skills ship with the installer)"
|
||||
exit 0
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$MOSAIC_HOME" "$MOSAIC_SKILLS_DIR" "$MOSAIC_LOCAL_SKILLS_DIR"
|
||||
|
||||
if [[ ! -d "$MOSAIC_SKILLS_DIR" ]]; then
|
||||
echo "[mosaic-skills] Canonical skills dir missing: $MOSAIC_SKILLS_DIR" >&2
|
||||
echo "[mosaic-skills] Canonical skills ship with the framework — reinstall or update the framework package" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Skills are linked into the MOSAIC-OWNED harness homes, never a base install.
|
||||
# Paths mirror the config-dir env vars the launcher injects (HARNESS_HOME_ENV in
|
||||
# commands/launch.js):
|
||||
# claude CLAUDE_CONFIG_DIR -> <home>/skills
|
||||
# pi PI_CODING_AGENT_DIR -> <home>/skills (replaces ~/.pi/agent)
|
||||
# codex CODEX_HOME -> <home>/skills
|
||||
# opencode XDG_CONFIG_HOME -> <home>/opencode/skills (XDG adds a level)
|
||||
link_targets=(
|
||||
"$MOSAIC_HOME/.claude/skills"
|
||||
"$MOSAIC_HOME/.codex/skills"
|
||||
"$MOSAIC_HOME/.opencode/opencode/skills"
|
||||
"$MOSAIC_HOME/.pi/skills"
|
||||
)
|
||||
|
||||
# Pre-isolation installs planted the same symlink farm directly in the operator's
|
||||
# base installs. Those are now orphaned: the launcher no longer reads them, but
|
||||
# they persist and make a "clean" base install look mosaic-managed.
|
||||
legacy_link_targets=(
|
||||
"$HOME/.claude/skills"
|
||||
"$HOME/.codex/skills"
|
||||
"$HOME/.config/opencode/skills"
|
||||
"$HOME/.pi/agent/skills"
|
||||
)
|
||||
|
||||
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
|
||||
local_real="$(readlink -f "$MOSAIC_LOCAL_SKILLS_DIR")"
|
||||
|
||||
# Build an associative array from the colon-separated whitelist for O(1) lookup.
|
||||
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
|
||||
declare -A _skill_whitelist=()
|
||||
_whitelist_active=0
|
||||
if [[ -n "$MOSAIC_INSTALL_SKILLS" ]]; then
|
||||
_whitelist_active=1
|
||||
IFS=':' read -ra _wl_items <<< "$MOSAIC_INSTALL_SKILLS"
|
||||
for _item in "${_wl_items[@]}"; do
|
||||
[[ -n "$_item" ]] && _skill_whitelist["$_item"]=1
|
||||
done
|
||||
fi
|
||||
|
||||
is_skill_selected() {
|
||||
local name="$1"
|
||||
if [[ $_whitelist_active -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
[[ -n "${_skill_whitelist[$name]:-}" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
link_skill_into_target() {
|
||||
local skill_path="$1"
|
||||
local target_dir="$2"
|
||||
|
||||
local name link_path
|
||||
name="$(basename "$skill_path")"
|
||||
|
||||
# Do not distribute hidden/system skill directories globally.
|
||||
if [[ "$name" == .* ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
# Respect the install whitelist (set during first-run wizard).
|
||||
if ! is_skill_selected "$name"; then
|
||||
return
|
||||
fi
|
||||
|
||||
link_path="$target_dir/$name"
|
||||
|
||||
if [[ -L "$link_path" ]]; then
|
||||
local raw_target resolved_target
|
||||
raw_target="$(readlink "$link_path")"
|
||||
resolved_target="$(node -e 'const p=require("node:path"); process.stdout.write(p.resolve(p.dirname(process.argv[1]), process.argv[2]));' "$link_path" "$raw_target")"
|
||||
if [[ "$resolved_target" == "$canonical_real/"* || "$resolved_target" == "$local_real/"* ]]; then
|
||||
ln -sfn "$skill_path" "$link_path"
|
||||
else
|
||||
echo "[mosaic-skills] Preserve foreign runtime symlink: $link_path"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -e "$link_path" ]]; then
|
||||
echo "[mosaic-skills] Preserve existing runtime-specific entry: $link_path"
|
||||
return
|
||||
fi
|
||||
|
||||
ln -s "$skill_path" "$link_path"
|
||||
}
|
||||
|
||||
is_mosaic_skill_name() {
|
||||
local name="$1"
|
||||
# -d follows symlinks; -L catches broken symlinks that still indicate ownership
|
||||
[[ -d "$MOSAIC_SKILLS_DIR/$name" || -L "$MOSAIC_SKILLS_DIR/$name" ]] && return 0
|
||||
[[ -d "$MOSAIC_LOCAL_SKILLS_DIR/$name" || -L "$MOSAIC_LOCAL_SKILLS_DIR/$name" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
prune_stale_links_in_target() {
|
||||
local target_dir="$1"
|
||||
|
||||
while IFS= read -r -d '' link_path; do
|
||||
local name resolved
|
||||
name="$(basename "$link_path")"
|
||||
|
||||
if is_mosaic_skill_name "$name"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# -m resolves lexical dangling targets too. If resolution fails, ownership
|
||||
# is unproven and the link must be preserved.
|
||||
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
||||
# $canonical_real must be length-checked BEFORE use as a prefix: if it were
|
||||
# ever empty, "$resolved" == "$canonical_real/"* collapses to == "/"* and
|
||||
# matches every absolute path. Combined with the is_mosaic_skill_name skip
|
||||
# above, that inverts the function precisely — it would delete exactly the
|
||||
# FOREIGN symlinks and keep the mosaic ones. (#1087, reported by mos-claude.)
|
||||
if [[ -n "$resolved" && -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
|
||||
rm -f "$link_path"
|
||||
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
||||
fi
|
||||
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
||||
}
|
||||
|
||||
# Remove mosaic-owned symlinks left in a base install by a pre-isolation sync.
|
||||
#
|
||||
# Ownership is proven by RESOLUTION, not by name: only links resolving inside the
|
||||
# canonical or local skills dirs are removed. Anything else — a real directory, a
|
||||
# link elsewhere, an unresolvable link — is left untouched. This mirrors the
|
||||
# refusal in commands/skill.js ("only symlinks pointing inside the Mosaic skills
|
||||
# directory are managed") and preserves e.g. codex's own `.system` dir.
|
||||
#
|
||||
# The directory itself is kept: mosaic-doctor warns when ~/.pi/agent/skills is
|
||||
# missing, and an empty dir is the correct end state, not an absent one.
|
||||
cleanup_legacy_target() {
|
||||
local target_dir="$1"
|
||||
local removed=0 kept=0
|
||||
|
||||
[[ -d "$target_dir" ]] || return 0
|
||||
|
||||
while IFS= read -r -d '' link_path; do
|
||||
local resolved owned=0
|
||||
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
||||
|
||||
# Guard the empty-prefix trap: an unset *_real would make "$resolved" == "/"*
|
||||
# match every absolute path and delete foreign links.
|
||||
if [[ -n "$resolved" ]]; then
|
||||
if [[ -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
|
||||
owned=1
|
||||
elif [[ -n "$local_real" && "$resolved" == "$local_real/"* ]]; then
|
||||
owned=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $owned -eq 1 ]]; then
|
||||
rm -f "$link_path"
|
||||
removed=$((removed + 1))
|
||||
else
|
||||
kept=$((kept + 1))
|
||||
fi
|
||||
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
||||
|
||||
if [[ $removed -gt 0 ]]; then
|
||||
echo "[mosaic-skills] Legacy cleanup: removed $removed mosaic symlink(s) from $target_dir (preserved $kept foreign)"
|
||||
fi
|
||||
}
|
||||
|
||||
for legacy in "${legacy_link_targets[@]}"; do
|
||||
# Skip anything that is also a current target, so isolation can never
|
||||
# self-destruct if the two lists ever overlap.
|
||||
skip=0
|
||||
for target in "${link_targets[@]}"; do
|
||||
[[ "$legacy" == "$target" ]] && skip=1
|
||||
done
|
||||
[[ $skip -eq 1 ]] && continue
|
||||
cleanup_legacy_target "$legacy"
|
||||
done
|
||||
|
||||
for target in "${link_targets[@]}"; do
|
||||
mkdir -p "$target"
|
||||
|
||||
# If target already resolves to canonical dir, skip to avoid self-link recursion/corruption.
|
||||
target_real="$(readlink -f "$target" 2>/dev/null || true)"
|
||||
if [[ -n "$target_real" && "$target_real" == "$canonical_real" ]]; then
|
||||
echo "[mosaic-skills] Skip target (already canonical): $target"
|
||||
continue
|
||||
fi
|
||||
|
||||
prune_stale_links_in_target "$target"
|
||||
|
||||
while IFS= read -r -d '' skill; do
|
||||
link_skill_into_target "$skill" "$target"
|
||||
done < <(find "$MOSAIC_SKILLS_DIR" -mindepth 1 -maxdepth 1 -type d -print0)
|
||||
|
||||
if [[ -d "$MOSAIC_LOCAL_SKILLS_DIR" ]]; then
|
||||
while IFS= read -r -d '' skill; do
|
||||
link_skill_into_target "$skill" "$target"
|
||||
done < <(find "$MOSAIC_LOCAL_SKILLS_DIR" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print0)
|
||||
fi
|
||||
|
||||
echo "[mosaic-skills] Linked skills into: $target"
|
||||
done
|
||||
|
||||
echo "[mosaic-skills] Complete"
|
||||
@@ -0,0 +1,124 @@
|
||||
# mosaic-sync-skills.ps1
|
||||
# Links the INSTALLED canonical skills into agent runtime skill directories.
|
||||
# Canonical skills ship inside the framework package and are installed into
|
||||
# ~/.config/mosaic/skills by the framework installer — there is no second
|
||||
# repository to clone. This script only maintains the runtime links.
|
||||
# Uses directory junctions (no elevation required) with fallback to copies.
|
||||
# PowerShell equivalent of mosaic-sync-skills (bash).
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
param(
|
||||
[switch]$LinkOnly,
|
||||
[switch]$NoLink,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$MosaicHome = if ($env:MOSAIC_HOME) { $env:MOSAIC_HOME } else { Join-Path $env:USERPROFILE ".config\mosaic" }
|
||||
$MosaicSkillsDir = Join-Path $MosaicHome "skills"
|
||||
$MosaicLocalSkillsDir = Join-Path $MosaicHome "skills-local"
|
||||
|
||||
if ($Help) {
|
||||
Write-Host @"
|
||||
Usage: mosaic-sync-skills.ps1 [-LinkOnly] [-NoLink] [-Help]
|
||||
|
||||
Link installed skills from ~/.config/mosaic/{skills,skills-local} into runtime
|
||||
skill directories using directory junctions. Canonical skills arrive with the
|
||||
framework installer; this script never clones or pulls a second repository.
|
||||
|
||||
Options:
|
||||
-LinkOnly Accepted for compatibility; linking is now the whole job
|
||||
-NoLink Do nothing (kept for compatibility)
|
||||
-Help Show help
|
||||
"@
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($NoLink) {
|
||||
Write-Host "[mosaic-skills] Nothing to do (-NoLink; canonical skills ship with the installer)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
foreach ($d in @($MosaicHome, $MosaicSkillsDir, $MosaicLocalSkillsDir)) {
|
||||
if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
|
||||
}
|
||||
|
||||
if (-not (Test-Path $MosaicSkillsDir)) {
|
||||
Write-Host "[mosaic-skills] Canonical skills dir missing: $MosaicSkillsDir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($NoLink) {
|
||||
Write-Host "[mosaic-skills] Canonical sync completed (link update skipped)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
function Link-SkillIntoTarget {
|
||||
param([string]$SkillPath, [string]$TargetDir)
|
||||
|
||||
$name = Split-Path $SkillPath -Leaf
|
||||
if ($name.StartsWith(".")) { return }
|
||||
|
||||
$linkPath = Join-Path $TargetDir $name
|
||||
|
||||
# Recreate only Mosaic-owned junctions/symlinks. Foreign reparse points are
|
||||
# runtime-owned and must never be clobbered by install/upgrade auto-sync.
|
||||
$existing = Get-Item $linkPath -Force -ErrorAction SilentlyContinue
|
||||
if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
||||
$rawTarget = @($existing.Target)[0]
|
||||
$candidate = if ([System.IO.Path]::IsPathRooted($rawTarget)) {
|
||||
$rawTarget
|
||||
}
|
||||
else {
|
||||
Join-Path (Split-Path $linkPath -Parent) $rawTarget
|
||||
}
|
||||
$resolvedTarget = [System.IO.Path]::GetFullPath($candidate)
|
||||
$canonicalRoot = [System.IO.Path]::GetFullPath($MosaicSkillsDir).TrimEnd('\') + '\'
|
||||
$localRoot = [System.IO.Path]::GetFullPath($MosaicLocalSkillsDir).TrimEnd('\') + '\'
|
||||
$owned = $resolvedTarget.StartsWith($canonicalRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
$resolvedTarget.StartsWith($localRoot, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
if (-not $owned) {
|
||||
Write-Host "[mosaic-skills] Preserve foreign runtime symlink: $linkPath"
|
||||
return
|
||||
}
|
||||
Remove-Item $linkPath -Force
|
||||
}
|
||||
elseif ($existing) {
|
||||
Write-Host "[mosaic-skills] Preserve existing runtime-specific entry: $linkPath"
|
||||
return
|
||||
}
|
||||
|
||||
# Try junction first, fall back to copy
|
||||
try {
|
||||
New-Item -ItemType Junction -Path $linkPath -Target $SkillPath -ErrorAction Stop | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-Host "[mosaic-skills] Junction failed for $name, falling back to copy"
|
||||
Copy-Item $SkillPath $linkPath -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
$linkTargets = @(
|
||||
(Join-Path $env:USERPROFILE ".claude\skills"),
|
||||
(Join-Path $env:USERPROFILE ".codex\skills"),
|
||||
(Join-Path $env:USERPROFILE ".config\opencode\skills")
|
||||
)
|
||||
|
||||
foreach ($target in $linkTargets) {
|
||||
if (-not (Test-Path $target)) { New-Item -ItemType Directory -Path $target -Force | Out-Null }
|
||||
|
||||
# Link canonical skills
|
||||
Get-ChildItem $MosaicSkillsDir -Directory | ForEach-Object {
|
||||
Link-SkillIntoTarget $_.FullName $target
|
||||
}
|
||||
|
||||
# Link local skills
|
||||
if (Test-Path $MosaicLocalSkillsDir) {
|
||||
Get-ChildItem $MosaicLocalSkillsDir -Directory | ForEach-Object {
|
||||
Link-SkillIntoTarget $_.FullName $target
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "[mosaic-skills] Linked skills into: $target"
|
||||
}
|
||||
|
||||
Write-Host "[mosaic-skills] Complete"
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# mosaic-upgrade — Clean up stale per-project files after Mosaic centralization
|
||||
#
|
||||
# SOUL.md → Now global at ~/.config/mosaic/SOUL.md (remove from projects)
|
||||
# CLAUDE.md → Now a thin pointer or removable (replace with pointer or remove)
|
||||
# AGENTS.md → Keep project-specific content, strip stale load-order directives
|
||||
#
|
||||
# Usage:
|
||||
# mosaic-upgrade [path] Upgrade a specific project (default: current dir)
|
||||
# mosaic-upgrade --all Scan ~/src/* for projects to upgrade
|
||||
# mosaic-upgrade --dry-run Show what would change without touching anything
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
|
||||
# Colors (disabled if not a terminal)
|
||||
if [[ -t 1 ]]; then
|
||||
GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m'
|
||||
CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m'
|
||||
else
|
||||
GREEN='' YELLOW='' RED='' CYAN='' BOLD='' DIM='' RESET=''
|
||||
fi
|
||||
|
||||
ok() { echo -e " ${GREEN}✓${RESET} $1"; }
|
||||
skip() { echo -e " ${DIM}–${RESET} $1"; }
|
||||
warn() { echo -e " ${YELLOW}⚠${RESET} $1"; }
|
||||
act() { echo -e " ${CYAN}→${RESET} $1"; }
|
||||
|
||||
DRY_RUN=false
|
||||
ALL=false
|
||||
TARGET=""
|
||||
SEARCH_ROOT="${HOME}/src"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
mosaic-upgrade — Clean up stale per-project files
|
||||
|
||||
Usage:
|
||||
mosaic-upgrade [path] Upgrade a specific project (default: cwd)
|
||||
mosaic-upgrade --all Scan ~/src/* for all git projects
|
||||
mosaic-upgrade --dry-run Preview changes without writing
|
||||
mosaic-upgrade --all --dry-run Preview all projects
|
||||
|
||||
After Mosaic centralization:
|
||||
SOUL.md → Removed (now global at ~/.config/mosaic/SOUL.md)
|
||||
CLAUDE.md → Replaced with thin pointer or removed
|
||||
AGENTS.md → Stale load-order sections stripped; project content preserved
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--all) ALL=true; shift ;;
|
||||
--root) SEARCH_ROOT="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-*) echo "Unknown flag: $1" >&2; usage >&2; exit 1 ;;
|
||||
*) TARGET="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Generate the thin CLAUDE.md pointer
|
||||
CLAUDE_POINTER='# CLAUDE Compatibility Pointer
|
||||
|
||||
This file exists so Claude Code sessions load Mosaic standards.
|
||||
|
||||
## MANDATORY — Read Before Any Response
|
||||
|
||||
BEFORE responding to any user message, READ `~/.config/mosaic/AGENTS.md`.
|
||||
|
||||
That file is the universal agent configuration. Do NOT respond until you have loaded it.
|
||||
Then read the project-local `AGENTS.md` in this repository for project-specific guidance.'
|
||||
|
||||
upgrade_project() {
|
||||
local project_dir="$1"
|
||||
local project_name
|
||||
project_name="$(basename "$project_dir")"
|
||||
local changed=false
|
||||
|
||||
echo -e "\n${BOLD}$project_name${RESET} ${DIM}($project_dir)${RESET}"
|
||||
|
||||
# ── SOUL.md ──────────────────────────────────────────────
|
||||
local soul="$project_dir/SOUL.md"
|
||||
if [[ -f "$soul" ]]; then
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
act "Would remove SOUL.md (now global at ~/.config/mosaic/SOUL.md)"
|
||||
else
|
||||
rm "$soul"
|
||||
ok "Removed SOUL.md (now global)"
|
||||
fi
|
||||
changed=true
|
||||
else
|
||||
skip "No SOUL.md (already clean)"
|
||||
fi
|
||||
|
||||
# ── CLAUDE.md ────────────────────────────────────────────
|
||||
local claude_md="$project_dir/CLAUDE.md"
|
||||
if [[ -f "$claude_md" ]]; then
|
||||
local claude_content
|
||||
claude_content="$(cat "$claude_md")"
|
||||
|
||||
# Check if it's already a thin pointer to AGENTS.md
|
||||
if echo "$claude_content" | grep -q "READ.*~/.config/mosaic/AGENTS.md"; then
|
||||
skip "CLAUDE.md already points to global AGENTS.md"
|
||||
else
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
act "Would replace CLAUDE.md with thin pointer to global AGENTS.md"
|
||||
else
|
||||
# Back up the original
|
||||
cp "$claude_md" "${claude_md}.mosaic-bak"
|
||||
echo "$CLAUDE_POINTER" > "$claude_md"
|
||||
ok "Replaced CLAUDE.md with pointer (backup: CLAUDE.md.mosaic-bak)"
|
||||
fi
|
||||
changed=true
|
||||
fi
|
||||
else
|
||||
skip "No CLAUDE.md"
|
||||
fi
|
||||
|
||||
# ── AGENTS.md (strip stale load-order, preserve project content) ─
|
||||
local agents="$project_dir/AGENTS.md"
|
||||
if [[ -f "$agents" ]]; then
|
||||
# Detect stale load-order patterns
|
||||
local has_stale=false
|
||||
|
||||
# Pattern 1: References to SOUL.md in load order
|
||||
if grep -qE "(Read|READ|Load).*SOUL\.md" "$agents" 2>/dev/null; then
|
||||
has_stale=true
|
||||
fi
|
||||
|
||||
# Pattern 2: Old "## Load Order" section that references centralized files
|
||||
if grep -q "## Load Order" "$agents" 2>/dev/null && \
|
||||
grep -qE "STANDARDS\.md|SOUL\.md" "$agents" 2>/dev/null; then
|
||||
has_stale=true
|
||||
fi
|
||||
|
||||
# Pattern 3: Old ~/.mosaic/ path (pre-centralization)
|
||||
if grep -q '~/.mosaic/' "$agents" 2>/dev/null; then
|
||||
has_stale=true
|
||||
fi
|
||||
|
||||
if [[ "$has_stale" == "true" ]]; then
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
act "Would strip stale load-order section from AGENTS.md"
|
||||
# Show what we detect
|
||||
if grep -qn "## Load Order" "$agents" 2>/dev/null; then
|
||||
local line
|
||||
line=$(grep -n "## Load Order" "$agents" | head -1 | cut -d: -f1)
|
||||
echo -e " ${DIM}Line $line: Found '## Load Order' section referencing SOUL.md/STANDARDS.md${RESET}"
|
||||
fi
|
||||
if grep -qn '~/.mosaic/' "$agents" 2>/dev/null; then
|
||||
echo -e " ${DIM}Found references to old ~/.mosaic/ path${RESET}"
|
||||
fi
|
||||
else
|
||||
cp "$agents" "${agents}.mosaic-bak"
|
||||
|
||||
# Strip the Load Order section (from "## Load Order" to next "##" or "---")
|
||||
if grep -q "## Load Order" "$agents"; then
|
||||
awk '
|
||||
/^## Load Order/ { skip=1; next }
|
||||
skip && /^(## |---)/ { skip=0 }
|
||||
skip { next }
|
||||
{ print }
|
||||
' "${agents}.mosaic-bak" > "$agents"
|
||||
fi
|
||||
|
||||
# Fix old ~/.mosaic/ → ~/.config/mosaic/
|
||||
if grep -q '~/.mosaic/' "$agents"; then
|
||||
sed -i 's|~/.mosaic/|~/.config/mosaic/|g' "$agents"
|
||||
fi
|
||||
|
||||
ok "Stripped stale load-order from AGENTS.md (backup: AGENTS.md.mosaic-bak)"
|
||||
fi
|
||||
changed=true
|
||||
else
|
||||
skip "AGENTS.md has no stale directives"
|
||||
fi
|
||||
else
|
||||
skip "No AGENTS.md"
|
||||
fi
|
||||
|
||||
# ── .claude/settings.json (leave alone) ──────────────────
|
||||
# Project-specific settings are fine — don't touch them.
|
||||
|
||||
if [[ "$changed" == "false" ]]; then
|
||||
echo -e " ${GREEN}Already up to date.${RESET}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo -e "${BOLD}Mode: DRY RUN (no files will be changed)${RESET}"
|
||||
fi
|
||||
|
||||
if [[ "$ALL" == "true" ]]; then
|
||||
echo -e "${BOLD}Scanning $SEARCH_ROOT for projects...${RESET}"
|
||||
count=0
|
||||
for dir in "$SEARCH_ROOT"/*/; do
|
||||
[[ -d "$dir/.git" ]] || continue
|
||||
upgrade_project "$dir"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo -e "\n${BOLD}Scanned $count projects.${RESET}"
|
||||
elif [[ -n "$TARGET" ]]; then
|
||||
if [[ ! -d "$TARGET" ]]; then
|
||||
echo "[mosaic-upgrade] ERROR: $TARGET is not a directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
upgrade_project "$TARGET"
|
||||
else
|
||||
upgrade_project "$(pwd)"
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo -e "\n${YELLOW}This was a dry run. Run without --dry-run to apply changes.${RESET}"
|
||||
fi
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
BOOTSTRAP_CMD="$MOSAIC_HOME/tools/_scripts/mosaic-bootstrap-repo"
|
||||
|
||||
roots=("$HOME/src")
|
||||
apply=0
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") [options]
|
||||
|
||||
Upgrade all Mosaic-linked slave repositories by re-running repo bootstrap with --force.
|
||||
|
||||
Options:
|
||||
--root <path> Add a search root (repeatable). Default: $HOME/src
|
||||
--apply Execute upgrades. Without this flag, script is dry-run.
|
||||
-h, --help Show this help
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--root)
|
||||
[[ $# -lt 2 ]] && { echo "Missing value for --root" >&2; exit 1; }
|
||||
roots+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--apply)
|
||||
apply=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -x "$BOOTSTRAP_CMD" ]]; then
|
||||
echo "[mosaic-upgrade] Missing bootstrap command: $BOOTSTRAP_CMD" >&2
|
||||
echo "[mosaic-upgrade] Install/refresh framework first: ~/.config/mosaic/install.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# De-duplicate roots while preserving order.
|
||||
uniq_roots=()
|
||||
for r in "${roots[@]}"; do
|
||||
skip=0
|
||||
for e in "${uniq_roots[@]}"; do
|
||||
[[ "$r" == "$e" ]] && { skip=1; break; }
|
||||
done
|
||||
[[ $skip -eq 0 ]] && uniq_roots+=("$r")
|
||||
done
|
||||
|
||||
candidates=()
|
||||
for root in "${uniq_roots[@]}"; do
|
||||
[[ -d "$root" ]] || continue
|
||||
|
||||
while IFS= read -r marker; do
|
||||
repo_dir="$(dirname "$(dirname "$marker")")"
|
||||
if [[ -d "$repo_dir/.git" ]]; then
|
||||
candidates+=("$repo_dir")
|
||||
fi
|
||||
done < <(find "$root" -type f -path '*/.mosaic/README.md' 2>/dev/null)
|
||||
done
|
||||
|
||||
# De-duplicate repos while preserving order.
|
||||
repos=()
|
||||
for repo in "${candidates[@]}"; do
|
||||
skip=0
|
||||
for existing in "${repos[@]}"; do
|
||||
[[ "$repo" == "$existing" ]] && { skip=1; break; }
|
||||
done
|
||||
[[ $skip -eq 0 ]] && repos+=("$repo")
|
||||
done
|
||||
|
||||
count_total=${#repos[@]}
|
||||
count_ok=0
|
||||
count_fail=0
|
||||
|
||||
mode="DRY-RUN"
|
||||
[[ $apply -eq 1 ]] && mode="APPLY"
|
||||
|
||||
echo "[mosaic-upgrade] Mode: $mode"
|
||||
echo "[mosaic-upgrade] Roots: ${uniq_roots[*]}"
|
||||
echo "[mosaic-upgrade] Linked repos found: $count_total"
|
||||
|
||||
if [[ $count_total -eq 0 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for repo in "${repos[@]}"; do
|
||||
if [[ $apply -eq 1 ]]; then
|
||||
if "$BOOTSTRAP_CMD" "$repo" --force >/dev/null; then
|
||||
echo "[mosaic-upgrade] upgraded: $repo"
|
||||
count_ok=$((count_ok + 1))
|
||||
else
|
||||
echo "[mosaic-upgrade] FAILED: $repo" >&2
|
||||
count_fail=$((count_fail + 1))
|
||||
fi
|
||||
else
|
||||
echo "[mosaic-upgrade] would upgrade: $repo"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $apply -eq 1 ]]; then
|
||||
echo "[mosaic-upgrade] complete: ok=$count_ok failed=$count_fail total=$count_total"
|
||||
[[ $count_fail -gt 0 ]] && exit 1
|
||||
fi
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# mosaic-wizard — Thin shell wrapper for the bundled TypeScript wizard
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
|
||||
# Look for the bundle in the installed location first, then the source repo
|
||||
WIZARD_BIN="$MOSAIC_HOME/dist/mosaic-wizard.mjs"
|
||||
if [[ ! -f "$WIZARD_BIN" ]]; then
|
||||
WIZARD_BIN="$(cd "$(dirname "$0")/.." && pwd)/dist/mosaic-wizard.mjs"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$WIZARD_BIN" ]]; then
|
||||
echo "[mosaic-wizard] ERROR: Wizard bundle not found." >&2
|
||||
echo "[mosaic-wizard] Re-install with: npm install -g @mosaic/mosaic" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "[mosaic-wizard] ERROR: Node.js is required but not found." >&2
|
||||
echo "[mosaic-wizard] Install Node.js 18+ from https://nodejs.org" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec node "$WIZARD_BIN" "$@"
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
# Covers the brain-home fleet-state check in `mosaic-doctor` (#1298 follow-up).
|
||||
#
|
||||
# The functions are extracted from the shipped script rather than copied here
|
||||
# (same discipline as test-fleet-transport-check.sh): a test that carries its
|
||||
# own copy of the logic keeps passing after the shipped copy changes.
|
||||
# Extraction is by exact function header and a closing brace in column one.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname "$0")" && pwd)
|
||||
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
|
||||
|
||||
extract_function() {
|
||||
local name="$1"
|
||||
local extracted
|
||||
extracted=$(sed -n "/^${name}() {/,/^}/p" "$DOCTOR")
|
||||
[ -n "$extracted" ] || fail "could not extract ${name}() from mosaic-doctor — script reshaped?"
|
||||
printf '%s\n' "$extracted"
|
||||
}
|
||||
|
||||
for fn in resolve_brain_home check_brain_home; do
|
||||
extract_function "$fn" >/dev/null
|
||||
done
|
||||
|
||||
warn_count=0
|
||||
warn() { warn_count=$((warn_count + 1)); echo "[WARN] $*"; }
|
||||
pass() { echo "[OK] $*"; return 0; }
|
||||
|
||||
eval "$(extract_function resolve_brain_home)"
|
||||
eval "$(extract_function check_brain_home)"
|
||||
|
||||
ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$ROOT"' EXIT
|
||||
|
||||
# note output is neither [OK] nor [WARN] — assert it directly in the case below.
|
||||
run_case() {
|
||||
# label, expect (ok|warn|note), then env assignments as arguments.
|
||||
# The check runs under `env` in a subshell, so its warn() also prints a
|
||||
# sentinel the parent counts — a subshell counter would never be visible.
|
||||
local label="$1" expect="$2"
|
||||
shift 2
|
||||
local out warns notes
|
||||
out=$(env "$@" bash -c "warn() { echo \"[WARN] \$*\"; }; note() { echo \"[NOTE] \$*\"; return 0; }; pass() { echo \"[OK] \$*\"; return 0; }; $(extract_function resolve_brain_home); $(extract_function check_brain_home); check_brain_home" 2>&1)
|
||||
warns=$(printf '%s\n' "$out" | grep -c '^\[WARN\]' || true)
|
||||
notes=$(printf '%s\n' "$out" | grep -c '^\[NOTE\]' || true)
|
||||
if [[ "$expect" == ok && "$warns" -eq 0 && "$notes" -eq 0 ]]; then
|
||||
echo "ok - $label"
|
||||
elif [[ "$expect" == warn && "$warns" -gt 0 ]]; then
|
||||
echo "ok - $label (warned)"
|
||||
elif [[ "$expect" == note && "$notes" -gt 0 ]]; then
|
||||
echo "ok - $label (noted)"
|
||||
else
|
||||
echo "output: $out" >&2
|
||||
fail "$label: expected $expect (warns=$warns notes=$notes)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── legacy: no brain, custom home never adopts ─────────────────────────────
|
||||
mkdir -p "$ROOT/legacy-mosaic/fleet/agents"
|
||||
run_case "custom home without brain stays legacy" ok \
|
||||
MOSAIC_HOME="$ROOT/legacy-mosaic" HOME="$ROOT"
|
||||
|
||||
# ── healthy brain at the default config home ───────────────────────────────
|
||||
mkdir -p "$ROOT/home/.config/mosaic" "$ROOT/home/.mosaic/fleet/agents"
|
||||
chmod 700 "$ROOT/home/.mosaic/fleet/agents"
|
||||
run_case "default home adopts healthy brain" ok \
|
||||
MOSAIC_HOME="$ROOT/home/.config/mosaic" HOME="$ROOT/home"
|
||||
|
||||
# ── explicit MOSAIC_BRAIN_HOME to a brain without fleet/agents → warn ──────
|
||||
mkdir -p "$ROOT/brain-noagents/fleet" "$ROOT/config"
|
||||
run_case "explicit brain without agents warns" warn \
|
||||
MOSAIC_HOME="$ROOT/config" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-noagents"
|
||||
|
||||
# ── explicit MOSAIC_BRAIN_HOME to a healthy brain → ok ─────────────────────
|
||||
mkdir -p "$ROOT/brain-ok/fleet/agents" "$ROOT/config2"
|
||||
chmod 700 "$ROOT/brain-ok/fleet/agents"
|
||||
run_case "explicit healthy brain passes" ok \
|
||||
MOSAIC_HOME="$ROOT/config2" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-ok"
|
||||
|
||||
# ── group-readable agents dir → warn (0700 boundary) ───────────────────────
|
||||
mkdir -p "$ROOT/brain-loose/fleet/agents" "$ROOT/config3"
|
||||
chmod 750 "$ROOT/brain-loose/fleet/agents"
|
||||
run_case "group-readable brain agents warns" warn \
|
||||
MOSAIC_HOME="$ROOT/config3" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-loose"
|
||||
|
||||
# ── symlinked agents dir → warn (managed-directory boundary) ───────────────
|
||||
mkdir -p "$ROOT/brain-link/real-agents" "$ROOT/brain-link/fleet" "$ROOT/config4"
|
||||
ln -s "$ROOT/brain-link/real-agents" "$ROOT/brain-link/fleet/agents"
|
||||
run_case "symlinked brain agents warns" warn \
|
||||
MOSAIC_HOME="$ROOT/config4" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-link"
|
||||
|
||||
# ── split state: envs in BOTH trees → warn ─────────────────────────────────
|
||||
mkdir -p "$ROOT/brain-split/fleet/agents" "$ROOT/config5/fleet/agents"
|
||||
chmod 700 "$ROOT/brain-split/fleet/agents" "$ROOT/config5/fleet/agents"
|
||||
touch "$ROOT/config5/fleet/agents/coder0.env.generated"
|
||||
run_case "env files in both trees warns (split state)" warn \
|
||||
MOSAIC_HOME="$ROOT/config5" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-split"
|
||||
|
||||
# ── config-home agents dir WITHOUT env files alongside a brain → ok ────────
|
||||
mkdir -p "$ROOT/brain-clean/fleet/agents" "$ROOT/config6/fleet/agents"
|
||||
chmod 700 "$ROOT/brain-clean/fleet/agents" "$ROOT/config6/fleet/agents"
|
||||
run_case "empty config-home agents dir alongside brain passes" ok \
|
||||
MOSAIC_HOME="$ROOT/config6" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-clean"
|
||||
|
||||
# ── greenfield brain-without-agents at the default home → note (#1288) ─────
|
||||
mkdir -p "$ROOT/gf-home/.config/mosaic/fleet" "$ROOT/gf-home/.mosaic"
|
||||
run_case "~/.mosaic without fleet/agents at default home notes the lock-in" note \
|
||||
MOSAIC_HOME="$ROOT/gf-home/.config/mosaic" HOME="$ROOT/gf-home"
|
||||
|
||||
# ── no ~/.mosaic at all at the default home → clean pass ─────────────────
|
||||
mkdir -p "$ROOT/plain-home/.config/mosaic/fleet"
|
||||
run_case "no ~/.mosaic at default home passes silently" ok \
|
||||
MOSAIC_HOME="$ROOT/plain-home/.config/mosaic" HOME="$ROOT/plain-home"
|
||||
|
||||
# ── custom (non-default) home with a stray ~/.mosaic → still silent ──────
|
||||
mkdir -p "$ROOT/custom-home/fleet/agents" "$ROOT/custom-home/.mosaic"
|
||||
run_case "custom home with stray ~/.mosaic stays silent" ok \
|
||||
MOSAIC_HOME="$ROOT/custom-home" HOME="$ROOT/custom-home"
|
||||
|
||||
echo "ok - mosaic-doctor brain-home check"
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env bash
|
||||
# Covers the #1240 fleet-transport checks in `mosaic-doctor` and in
|
||||
# `tools/install.sh`.
|
||||
#
|
||||
# Both checks answer the same question — "can a seat actually launch on this
|
||||
# host?" — from two different places, because the installer has to be able to
|
||||
# answer it before the framework's own scripts are guaranteed to be on disk.
|
||||
# Two implementations of one rule is exactly the shape that drifts, so this
|
||||
# harness drives BOTH, in one file, from the same table of cases.
|
||||
#
|
||||
# The functions are extracted from the shipped scripts rather than copied here.
|
||||
# A test that carries its own copy of the logic is a test that keeps passing
|
||||
# after the shipped copy changes — the failure mode this whole change is about.
|
||||
# Extraction is by exact function header and a closing brace in column one; if
|
||||
# either script is reshaped so that stops matching, the extraction yields
|
||||
# nothing and this fails loudly instead of silently measuring an empty string.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
|
||||
# framework/tools/_scripts -> framework/tools -> framework -> mosaic -> packages -> repo
|
||||
INSTALLER=$(cd -- "$SCRIPT_DIR/../../../../.." && pwd)/tools/install.sh
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
|
||||
[ -f "$INSTALLER" ] || fail "missing install.sh at $INSTALLER"
|
||||
|
||||
ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$ROOT"' EXIT
|
||||
|
||||
# The cases below run with PATH set to a directory that deliberately does not
|
||||
# contain a shell, and a PATH assignment on a command also governs how that
|
||||
# command is looked up — so bash has to be named absolutely or it becomes the
|
||||
# thing that is missing.
|
||||
BASH_BIN=$(command -v bash) || fail "host is missing 'bash'"
|
||||
|
||||
# A PATH containing exactly the utilities these functions use and nothing else.
|
||||
# The absent-transport cases are only meaningful on a PATH where the transport
|
||||
# is genuinely unresolvable, and this host (like most) has tmux in /usr/bin —
|
||||
# so the system path cannot be part of the path under test.
|
||||
FAKE_BIN="$ROOT/bin"
|
||||
mkdir -p "$FAKE_BIN"
|
||||
for utility in sed head tr awk; do
|
||||
utility_path=$(command -v "$utility") || fail "host is missing '$utility'"
|
||||
ln -s "$utility_path" "$FAKE_BIN/$utility"
|
||||
done
|
||||
|
||||
if PATH="$FAKE_BIN" command -v tmux >/dev/null 2>&1; then
|
||||
fail "'tmux' is resolvable on the minimal test path; absent-transport cases are not measurable"
|
||||
fi
|
||||
|
||||
# Extract a function by its exact header, up to a closing brace in column one.
|
||||
extract_function() {
|
||||
local source_file="$1"
|
||||
local function_name="$2"
|
||||
local destination="$3"
|
||||
|
||||
awk -v name="$function_name" '
|
||||
$0 == name "() {" { collecting = 1 }
|
||||
collecting { print }
|
||||
collecting && $0 == "}" { exit }
|
||||
' "$source_file" > "$destination"
|
||||
|
||||
grep -qF "$function_name() {" "$destination" ||
|
||||
fail "could not extract '$function_name' from $source_file — has it been renamed or reshaped?"
|
||||
# An unterminated extraction would be a syntax error the moment it is sourced,
|
||||
# but saying so here names the cause instead of leaving a bash parse error.
|
||||
bash -n "$destination" ||
|
||||
fail "extracted '$function_name' does not parse; the closing brace was probably not found"
|
||||
}
|
||||
|
||||
extract_function "$DOCTOR" fleet_declared_transport "$ROOT/doctor-declared.sh"
|
||||
extract_function "$DOCTOR" check_fleet_transport "$ROOT/doctor-check.sh"
|
||||
extract_function "$INSTALLER" check_fleet_transport "$ROOT/installer-check.sh"
|
||||
|
||||
# Build a MOSAIC_HOME, optionally with a roster declaring a transport.
|
||||
make_home() {
|
||||
local home="$ROOT/$1"
|
||||
local declared="${2-}"
|
||||
|
||||
rm -rf "$home"
|
||||
mkdir -p "$home"
|
||||
if [ -n "$declared" ]; then
|
||||
mkdir -p "$home/fleet"
|
||||
cat > "$home/fleet/roster.yaml" <<EOF
|
||||
version: 2
|
||||
generation: 1
|
||||
transport: $declared
|
||||
agents: []
|
||||
EOF
|
||||
fi
|
||||
printf '%s\n' "$home"
|
||||
}
|
||||
|
||||
# Run the doctor's check against a given home and path, capturing which
|
||||
# reporter the check chose. The real `pass` prints only under `--verbose` and
|
||||
# the real `warn` always prints; these stubs make both unconditional on
|
||||
# purpose, because what is under test is the severity the check selects, not
|
||||
# whether the default verbosity happens to show it. A check that warned where
|
||||
# it should pass would otherwise be invisible here.
|
||||
run_doctor_check() {
|
||||
local home="$1"
|
||||
local path="$2"
|
||||
|
||||
MOSAIC_HOME="$home" PATH="$path" "$BASH_BIN" --noprofile --norc -c '
|
||||
set -euo pipefail
|
||||
warn() { echo "[WARN] $*"; }
|
||||
pass() { echo "[OK] $*"; }
|
||||
MOSAIC_HOME="$1"
|
||||
source "$2"
|
||||
source "$3"
|
||||
check_fleet_transport
|
||||
' _ "$home" "$ROOT/doctor-declared.sh" "$ROOT/doctor-check.sh" 2>&1
|
||||
}
|
||||
|
||||
run_installer_check() {
|
||||
local home="$1"
|
||||
local path="$2"
|
||||
|
||||
MOSAIC_HOME="$home" PATH="$path" "$BASH_BIN" --noprofile --norc -c '
|
||||
set -euo pipefail
|
||||
warn() { echo "[WARN] $*"; }
|
||||
C="" RESET=""
|
||||
MOSAIC_HOME="$1"
|
||||
source "$2"
|
||||
check_fleet_transport
|
||||
' _ "$home" "$ROOT/installer-check.sh" 2>&1
|
||||
}
|
||||
|
||||
# A transport that exists. Named tmux because that is what the default roster
|
||||
# declares; the binary never runs, it only has to resolve.
|
||||
PRESENT_BIN="$ROOT/present-bin"
|
||||
mkdir -p "$PRESENT_BIN"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$PRESENT_BIN/tmux"
|
||||
chmod +x "$PRESENT_BIN/tmux"
|
||||
PATH_WITH_TMUX="$PRESENT_BIN:$FAKE_BIN"
|
||||
|
||||
# ── absent, no roster ────────────────────────────────────────────────────────
|
||||
# Nothing has been configured yet, so the honest thing to point at is `init`.
|
||||
home=$(make_home no-roster)
|
||||
|
||||
output=$(run_doctor_check "$home" "$FAKE_BIN")
|
||||
echo "$output" | grep -qF '[WARN]' || fail "doctor did not warn when tmux was absent"
|
||||
echo "$output" | grep -qF 'tmux' || fail "doctor warning did not name the transport"
|
||||
echo "$output" | grep -qF 'mosaic fleet init' || fail "doctor did not point a rosterless host at init"
|
||||
|
||||
output=$(run_installer_check "$home" "$FAKE_BIN")
|
||||
echo "$output" | grep -qF '[WARN]' || fail "installer did not warn when tmux was absent"
|
||||
echo "$output" | grep -qF 'reports success and no seat comes up' ||
|
||||
fail "installer warning did not say what the missing transport actually breaks"
|
||||
|
||||
# ── absent, roster present ───────────────────────────────────────────────────
|
||||
# A configured fleet that cannot launch is a stronger statement than a
|
||||
# hypothetical one, and the message says so.
|
||||
home=$(make_home with-roster tmux)
|
||||
|
||||
output=$(run_doctor_check "$home" "$FAKE_BIN")
|
||||
echo "$output" | grep -qF '[WARN]' || fail "doctor did not warn with a roster present and tmux absent"
|
||||
echo "$output" | grep -qF 'roster' || fail "doctor did not mention the roster it found"
|
||||
echo "$output" | grep -qF 'mosaic fleet start' || fail "doctor did not point a configured host at start"
|
||||
|
||||
# ── present ──────────────────────────────────────────────────────────────────
|
||||
# Silence from the installer, and a pass (not a warning) from the audit.
|
||||
for home_name in no-roster with-roster; do
|
||||
home="$ROOT/$home_name"
|
||||
|
||||
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
|
||||
if echo "$output" | grep -qF '[WARN]'; then
|
||||
fail "doctor warned about the transport while tmux was present ($home_name)"
|
||||
fi
|
||||
echo "$output" | grep -qF '[OK]' || fail "doctor did not record a pass with tmux present ($home_name)"
|
||||
|
||||
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
|
||||
if [ -n "$output" ]; then
|
||||
fail "installer was not silent with tmux present ($home_name): $output"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── the roster declares something other than tmux ────────────────────────────
|
||||
# The roster is read, not assumed. A host that declares a different transport
|
||||
# is told about the binary it actually needs, and never about tmux — being sent
|
||||
# to install the wrong package is worse than no advice at all.
|
||||
home=$(make_home other-transport zellij)
|
||||
|
||||
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
|
||||
echo "$output" | grep -qF 'zellij' || fail "doctor ignored the roster's declared transport"
|
||||
if echo "$output" | grep -qF 'tmux'; then
|
||||
fail "doctor named tmux for a host whose roster declares zellij"
|
||||
fi
|
||||
|
||||
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
|
||||
echo "$output" | grep -qF 'zellij' || fail "installer ignored the roster's declared transport"
|
||||
if echo "$output" | grep -qF 'tmux'; then
|
||||
fail "installer named tmux for a host whose roster declares zellij"
|
||||
fi
|
||||
|
||||
# ── a quoted or trailing-comment transport value ─────────────────────────────
|
||||
# YAML permits both and neither is exotic; a check that installs `tmux"` or
|
||||
# reads `tmux # default` as a binary name would send the operator nowhere.
|
||||
home=$(make_home quoted-transport '"tmux" # the only transport today')
|
||||
|
||||
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
|
||||
echo "$output" | grep -qF '[OK] Fleet transport available: tmux' ||
|
||||
fail "doctor did not parse a quoted/commented transport value: $output"
|
||||
|
||||
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
|
||||
if [ -n "$output" ]; then
|
||||
fail "installer did not parse a quoted/commented transport value: $output"
|
||||
fi
|
||||
|
||||
echo "ok - fleet transport checks (mosaic-doctor + install.sh)"
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for issue #869 Point-1 C2 — the install-ordering guard
|
||||
# wired into mosaic-link-runtime-assets.
|
||||
#
|
||||
# Root cause under test: mosaic-link-runtime-assets copies
|
||||
# runtime/claude/settings.json (which embeds the PreToolUse mutator-gate.py
|
||||
# hook and the Stop receipt-observer-client.py hook) straight into
|
||||
# ~/.claude/settings.json, unconditionally. If the lease-broker activation
|
||||
# half cannot be confirmed on this host, wiring those hooks bricks it with a
|
||||
# fail-closed gate that can never be satisfied.
|
||||
#
|
||||
# This harness never invokes a real `mosaic` CLI build — it stubs the
|
||||
# `__link-claude-settings` contract with a fake `mosaic` on PATH so the shell
|
||||
# WIRING (does mosaic-link-runtime-assets call out correctly? does it
|
||||
# propagate a degraded outcome? does it still copy every other runtime file?
|
||||
# does --allow-inactive-enforcement forward through?) is exercised
|
||||
# independently of the TS guard's own logic (already covered by
|
||||
# install-ordering-guard.spec.ts). It also exercises the no-mosaic-on-PATH
|
||||
# python3 fallback directly.
|
||||
#
|
||||
# Scenarios:
|
||||
# 1. probe=true (fake mosaic exits 0) -> settings.json copied, script exits 0.
|
||||
# 2. probe=false (fake mosaic exits 1) -> script exits 1 (guard_degraded
|
||||
# propagated), but every OTHER runtime file is still copied.
|
||||
# 3. probe=false + --allow-inactive-enforcement -> the flag is forwarded to
|
||||
# the fake mosaic stub.
|
||||
# 4. No `mosaic` on PATH at all (activation unconfirmable) -> the python3
|
||||
# fallback strips the enforcement hooks itself and the script exits 1.
|
||||
# 5. No `mosaic` on PATH + --allow-inactive-enforcement -> the python3
|
||||
# fallback wires the hooks AS-IS and the script exits 0.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LINK_SCRIPT="$SCRIPT_DIR/mosaic-link-runtime-assets"
|
||||
|
||||
TMP_ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_ROOT"' EXIT
|
||||
|
||||
fail=0
|
||||
fail_msg() {
|
||||
echo "FAIL: $*" >&2
|
||||
fail=1
|
||||
}
|
||||
|
||||
FIXTURE_SETTINGS='{
|
||||
"model": "opus",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{ "matcher": ".*", "hooks": [ { "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude" } ] },
|
||||
{ "matcher": "Write|Edit|MultiEdit", "hooks": [ { "type": "command", "command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh" } ] }
|
||||
],
|
||||
"Stop": [
|
||||
{ "hooks": [
|
||||
{ "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude" },
|
||||
{ "type": "command", "command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
}'
|
||||
|
||||
# Sets up a fresh $MOSAIC_HOME/runtime/claude/{settings.json,CLAUDE.md,
|
||||
# hooks-config.json,context7-integration.md} + fresh $HOME, echoes both paths
|
||||
# space-separated for the caller to `read`.
|
||||
new_scenario_dirs() {
|
||||
local scenario="$1"
|
||||
local base="$TMP_ROOT/$scenario"
|
||||
local mosaic_home="$base/mosaic-home"
|
||||
local home="$base/home"
|
||||
mkdir -p "$mosaic_home/runtime/claude" "$home"
|
||||
printf '%s' "$FIXTURE_SETTINGS" > "$mosaic_home/runtime/claude/settings.json"
|
||||
echo "claude.md fixture" > "$mosaic_home/runtime/claude/CLAUDE.md"
|
||||
echo '{"hooks":{}}' > "$mosaic_home/runtime/claude/hooks-config.json"
|
||||
echo "context7 fixture" > "$mosaic_home/runtime/claude/context7-integration.md"
|
||||
echo "$mosaic_home" "$home"
|
||||
}
|
||||
|
||||
settings_has_marker() {
|
||||
local file="$1" marker="$2"
|
||||
[[ -f "$file" ]] && grep -q "$marker" "$file"
|
||||
}
|
||||
|
||||
# A fake `mosaic` binary implementing only the __link-claude-settings contract
|
||||
# this harness needs: writes dest verbatim (fixture is unmodified either way —
|
||||
# this stub only exercises the CALL CONTRACT, not the TS strip logic, which
|
||||
# has its own vitest coverage) and exits with the code the scenario wants.
|
||||
# Records the args it was called with so the harness can assert forwarding.
|
||||
make_fake_mosaic() {
|
||||
local bin_dir="$1" exit_code="$2"
|
||||
mkdir -p "$bin_dir"
|
||||
cat > "$bin_dir/mosaic" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "\$@" > "$bin_dir/mosaic.args"
|
||||
if [[ "\$1" == "__link-claude-settings" ]]; then
|
||||
cp "\$2" "\$3"
|
||||
exit $exit_code
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$bin_dir/mosaic"
|
||||
}
|
||||
|
||||
# --- Scenario 1: probe=true (fake mosaic exits 0) ---------------------------
|
||||
read -r MOSAIC_HOME_1 HOME_1 < <(new_scenario_dirs scenario1)
|
||||
BIN_1="$TMP_ROOT/scenario1/bin"
|
||||
make_fake_mosaic "$BIN_1" 0
|
||||
|
||||
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_1" HOME="$HOME_1" PATH="$BIN_1:$PATH" "$LINK_SCRIPT" 2>&1)
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -eq 0 ]] || fail_msg "scenario1 (probe=true): expected exit 0, got $STATUS. Output: $OUTPUT"
|
||||
[[ -f "$HOME_1/.claude/settings.json" ]] || fail_msg "scenario1: settings.json was not copied"
|
||||
|
||||
# --- Scenario 2: probe=false (fake mosaic exits 1) --------------------------
|
||||
read -r MOSAIC_HOME_2 HOME_2 < <(new_scenario_dirs scenario2)
|
||||
BIN_2="$TMP_ROOT/scenario2/bin"
|
||||
make_fake_mosaic "$BIN_2" 1
|
||||
|
||||
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_2" HOME="$HOME_2" PATH="$BIN_2:$PATH" "$LINK_SCRIPT" 2>&1)
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -ne 0 ]] || fail_msg "scenario2 (probe=false, default): expected non-zero exit, got 0. Output: $OUTPUT"
|
||||
[[ -f "$HOME_2/.claude/CLAUDE.md" ]] || fail_msg "scenario2: CLAUDE.md was NOT copied even though it is independent of the settings.json guard"
|
||||
[[ -f "$HOME_2/.claude/hooks-config.json" ]] || fail_msg "scenario2: hooks-config.json was NOT copied"
|
||||
[[ -f "$HOME_2/.claude/context7-integration.md" ]] || fail_msg "scenario2: context7-integration.md was NOT copied"
|
||||
case "$OUTPUT" in
|
||||
*"NOT be wired"*|*"NOT wired"*) ;;
|
||||
*) fail_msg "scenario2: expected an actionable degraded-wiring message in output, got: $OUTPUT" ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 3: probe=false + --allow-inactive-enforcement forwards the flag
|
||||
read -r MOSAIC_HOME_3 HOME_3 < <(new_scenario_dirs scenario3)
|
||||
BIN_3="$TMP_ROOT/scenario3/bin"
|
||||
make_fake_mosaic "$BIN_3" 0
|
||||
|
||||
MOSAIC_HOME="$MOSAIC_HOME_3" HOME="$HOME_3" PATH="$BIN_3:$PATH" "$LINK_SCRIPT" --allow-inactive-enforcement >/dev/null 2>&1
|
||||
RECORDED_ARGS="$(cat "$BIN_3/mosaic.args" 2>/dev/null || true)"
|
||||
case "$RECORDED_ARGS" in
|
||||
*"--allow-inactive-enforcement"*) ;;
|
||||
*) fail_msg "scenario3: --allow-inactive-enforcement was not forwarded to the mosaic CLI invocation (got: '$RECORDED_ARGS')" ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 4: no `mosaic` on PATH at all -> python3 fallback strips hooks
|
||||
read -r MOSAIC_HOME_4 HOME_4 < <(new_scenario_dirs scenario4)
|
||||
EMPTY_BIN="$TMP_ROOT/scenario4/empty-bin"
|
||||
mkdir -p "$EMPTY_BIN"
|
||||
# A PATH containing only python3 (for the fallback) + core utils, no mosaic.
|
||||
FALLBACK_PATH="$EMPTY_BIN:/usr/bin:/bin"
|
||||
|
||||
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_4" HOME="$HOME_4" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" 2>&1)
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -ne 0 ]] || fail_msg "scenario4 (no mosaic on PATH, default): expected non-zero exit, got 0. Output: $OUTPUT"
|
||||
if settings_has_marker "$HOME_4/.claude/settings.json" "mutator-gate.py"; then
|
||||
fail_msg "scenario4: mutator-gate.py hook was wired even though mosaic could not be resolved (activation unconfirmable)"
|
||||
fi
|
||||
if settings_has_marker "$HOME_4/.claude/settings.json" "receipt-observer-client.py"; then
|
||||
fail_msg "scenario4: receipt-observer-client.py hook was wired even though mosaic could not be resolved"
|
||||
fi
|
||||
if ! settings_has_marker "$HOME_4/.claude/settings.json" "prevent-memory-write.sh"; then
|
||||
fail_msg "scenario4: the unrelated prevent-memory-write.sh hook was incorrectly dropped too"
|
||||
fi
|
||||
|
||||
# --- Scenario 5: no `mosaic` on PATH + --allow-inactive-enforcement --------
|
||||
read -r MOSAIC_HOME_5 HOME_5 < <(new_scenario_dirs scenario5)
|
||||
|
||||
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_5" HOME="$HOME_5" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" --allow-inactive-enforcement 2>&1)
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -eq 0 ]] || fail_msg "scenario5 (no mosaic, opt-out): expected exit 0, got $STATUS. Output: $OUTPUT"
|
||||
if ! settings_has_marker "$HOME_5/.claude/settings.json" "mutator-gate.py"; then
|
||||
fail_msg "scenario5: mutator-gate.py hook should have been wired (explicit opt-out set)"
|
||||
fi
|
||||
case "$OUTPUT" in
|
||||
*"WARNING"*"--allow-inactive-enforcement"*) ;;
|
||||
*) fail_msg "scenario5: expected a loud WARNING mentioning --allow-inactive-enforcement, got: $OUTPUT" ;;
|
||||
esac
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "install-ordering-guard regression passed (5/5 scenarios)"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Source only the prompt helpers; executing mosaic-init itself requires templates.
|
||||
source <(head -n 144 "$(dirname "$0")/mosaic-init")
|
||||
|
||||
rm -f /tmp/pwned
|
||||
payload='literal "$(touch /tmp/pwned)"'
|
||||
AGENT_NAME=""
|
||||
prompt_if_empty AGENT_NAME "Agent name" <<<"$payload"
|
||||
|
||||
[[ "$AGENT_NAME" == "$payload" ]] || {
|
||||
echo "FAIL: prompt answer did not round-trip literally" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ ! -e /tmp/pwned ]] || {
|
||||
echo "FAIL: prompt answer executed code" >&2
|
||||
rm -f /tmp/pwned
|
||||
exit 1
|
||||
}
|
||||
echo "mosaic-init RCE regression: PASS"
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# Covers the structure-anchor provisioning check in `mosaic-doctor` (T51 WP0b).
|
||||
#
|
||||
# Same discipline as test-brain-home-check.sh: functions are extracted from the
|
||||
# shipped script (exact header + closing brace), never copied — a test carrying
|
||||
# its own copy of the logic keeps passing after the shipped copy changes.
|
||||
#
|
||||
# Four contract states (charter T51P2WP0B-20260824):
|
||||
# 1. both present+nonempty -> pass ([OK]), no warns, no notes
|
||||
# 2a. host missing, brain set -> warn naming MOSAIC_HOST_ROOT + launchers
|
||||
# 2b. brain missing, host set -> warn naming MOSAIC_BRAIN_HOME + launchers
|
||||
# 2c. present-but-EMPTY counts as missing (warns; NEVER informational)
|
||||
# 3. neither present -> informational notes, NON-AUTHORITATIVE, never warn
|
||||
# Arms include genuinely-UNSET (env -u) forms, not only empty strings.
|
||||
# Red control: empty-vs-unset distinction removed in a mutated copy -> suite red.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname "$0")" && pwd)
|
||||
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
|
||||
|
||||
extract_function() {
|
||||
local name="$1"
|
||||
local extracted
|
||||
extracted=$(sed -n "/^${name}() {/,/^}/p" "$DOCTOR")
|
||||
[ -n "$extracted" ] || fail "could not extract ${name}() from mosaic-doctor — script reshaped?"
|
||||
printf '%s\n' "$extracted"
|
||||
}
|
||||
|
||||
for fn in check_structure_anchor_provisioning; do
|
||||
extract_function "$fn" >/dev/null
|
||||
done
|
||||
|
||||
# run_case LABEL EXPECT(ok|warn|note) [env assignments as args; -u VAR tokens for unset]
|
||||
run_case() {
|
||||
local label="$1" expect="$2"
|
||||
shift 2
|
||||
local envs=() unsets=()
|
||||
local a
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
-u:*) unsets+=("${a#-u:}") ;;
|
||||
*) envs+=("$a") ;;
|
||||
esac
|
||||
done
|
||||
local out warns notes oks
|
||||
# build the env command with proper -u flags (array expansion must not
|
||||
# glue '-u VAR' into one word)
|
||||
local cmd=(env)
|
||||
local e u
|
||||
# env(1) parses options only before the first assignment — -u flags FIRST
|
||||
for u in "${unsets[@]:-}"; do [ -n "$u" ] && cmd+=(-u "$u"); done
|
||||
for e in "${envs[@]:-}"; do [ -n "$e" ] && cmd+=("$e"); done
|
||||
cmd+=(bash -c "warn() { echo \"[WARN] \$*\"; }; note() { echo \"[NOTE] \$*\"; return 0; }; pass() { echo \"[OK] \$*\"; return 0; }; $(extract_function check_structure_anchor_provisioning); check_structure_anchor_provisioning")
|
||||
out=$("${cmd[@]}" 2>&1)
|
||||
warns=$(printf '%s\n' "$out" | grep -c '^\[WARN\]' || true)
|
||||
notes=$(printf '%s\n' "$out" | grep -c '^\[NOTE\]' || true)
|
||||
oks=$(printf '%s\n' "$out" | grep -c '^\[OK\]' || true)
|
||||
if [[ "$expect" == ok && "$oks" -gt 0 && "$warns" -eq 0 && "$notes" -eq 0 ]]; then
|
||||
echo "ok - $label"
|
||||
elif [[ "$expect" == warn && "$warns" -ge 1 && "$notes" -eq 0 ]]; then
|
||||
echo "ok - $label (warned x$warns)"
|
||||
elif [[ "$expect" == note && "$notes" -gt 0 && "$warns" -eq 0 ]]; then
|
||||
echo "ok - $label (noted)"
|
||||
else
|
||||
echo "output: $out" >&2
|
||||
fail "$label: expected $expect (oks=$oks warns=$warns notes=$notes)"
|
||||
fi
|
||||
}
|
||||
|
||||
ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$ROOT"' EXIT
|
||||
HOST="$ROOT/host"
|
||||
BRAIN="$ROOT/brain"
|
||||
|
||||
# ── state 1: both present + nonempty → pass ────────────────────────────────
|
||||
run_case "both anchors present passes" ok \
|
||||
MOSAIC_HOST_ROOT="$HOST" MOSAIC_BRAIN_HOME="$BRAIN"
|
||||
|
||||
# ── state 2a: host missing (unset), brain set → exactly one warn ───────────
|
||||
run_case "unset host root warns" warn \
|
||||
-u:MOSAIC_HOST_ROOT MOSAIC_BRAIN_HOME="$BRAIN"
|
||||
|
||||
# ── state 2b: brain missing (unset), host set → exactly one warn ───────────
|
||||
run_case "unset brain home warns" warn \
|
||||
MOSAIC_HOST_ROOT="$HOST" -u:MOSAIC_BRAIN_HOME
|
||||
|
||||
# ── state 2c-empty: present-but-empty counts as missing ────────────────────
|
||||
run_case "empty-string host root warns (empty != set)" warn \
|
||||
MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME="$BRAIN"
|
||||
run_case "empty-string brain home warns (empty != set)" warn \
|
||||
MOSAIC_HOST_ROOT="$HOST" MOSAIC_BRAIN_HOME=
|
||||
|
||||
# ── state 3: neither present (genuinely unset) → notes, never warn ─────────
|
||||
run_case "both unset yields non-authoritative notes" note \
|
||||
-u:MOSAIC_HOST_ROOT -u:MOSAIC_BRAIN_HOME
|
||||
run_case "both empty-string warns (empty is present, not absent)" warn \
|
||||
MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME=
|
||||
run_case "host empty + brain unset warns" warn \
|
||||
MOSAIC_HOST_ROOT= -u:MOSAIC_BRAIN_HOME
|
||||
run_case "host unset + brain empty warns" warn \
|
||||
-u:MOSAIC_HOST_ROOT MOSAIC_BRAIN_HOME=
|
||||
|
||||
# ── red control (mutation): presence tracking removed → red ────────────────
|
||||
# Mutant regresses to the reviewed defect shape: presence derived from
|
||||
# NONEMPTINESS (the `${VAR:-}` collapse) instead of true -v tracking. Both-empty
|
||||
# then looks genuinely-absent and is mis-filed as informational; the both-empty
|
||||
# warn arm above finds no WARN and the suite reds.
|
||||
MUT="$ROOT/mosaic-doctor.mutant"
|
||||
sed 's/\[\[ -v MOSAIC_HOST_ROOT \]\] \&\& host_set=1/[[ -n "${MOSAIC_HOST_ROOT:-}" ]] \&\& host_set=1/; s/\[\[ -v MOSAIC_BRAIN_HOME \]\] \&\& brain_set=1/[[ -n "${MOSAIC_BRAIN_HOME:-}" ]] \&\& brain_set=1/' \
|
||||
"$DOCTOR" > "$MUT"
|
||||
if cmp -s "$DOCTOR" "$MUT"; then
|
||||
echo "SKIP red control (mutation anchor not found — sed pattern drifted)" >&2
|
||||
else
|
||||
mut_fn=$(sed -n "/^check_structure_anchor_provisioning() {/,/^}/p" "$MUT")
|
||||
outm=$(env MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME= bash -c \
|
||||
"warn() { echo \"[WARN] \$*\"; }; note() { echo \"[NOTE] \$*\"; return 0; }; pass() { echo \"[OK] \$*\"; return 0; }; $mut_fn; check_structure_anchor_provisioning" 2>&1)
|
||||
if printf '%s\n' "$outm" | grep -q '^\[NOTE\]'; then
|
||||
echo "ok - red control bites (mutant collapses empty into informational; shipped does not)"
|
||||
else
|
||||
fail "red control did not reproduce the regression shape (mutant output unexpected)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "structure anchor doctor check: all arms passed"
|
||||
Reference in New Issue
Block a user