Compare commits

..
Author SHA1 Message Date
jarvis d6434fc941 scratchpads: 2026-08-17 fleet identity/comms/mosaic-tree continuation record
ci/woodpecker/pr/ci Pipeline was successful
Anchors the sb-it-1-dt session's outputs in this repo so continuation
does not depend on jarvis-brain (P0, not retained): ~/.mosaic tree model
as-built (incl. fleet/agents store + *.env.generated overlays), decisions
register (MOSAIC-D-001/-02 pending ruling, tooling-migration direction,
watcher-provisioning-at-instantiation), #1295 pointers, sequenced open
work queue, and rules for tonight's homelab agents (lane next, #1280
attribution, evidence-file protection).
2026-08-17 17:08:27 -05:00
20 changed files with 95 additions and 671 deletions
-27
View File
@@ -12,33 +12,6 @@ The default tmux socket is `mosaic-fleet` so fleet commands do not touch the
default tmux server. The roster is the desired-state authority; generated environment files are
rebuildable projections, never a second source of configuration.
## Brain-home split (fleet state vs framework templates)
When a mosaic-brain clone is present, fleet **state** resolves from the brain
home while framework templates and dispatch state stay in the config home
(three-tree model, canon `docs/STRUCTURE-CANON.md` §2):
| Path | Without brain (legacy) | With brain |
| ------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------ |
| `fleet/agents/<seat>.env.*` | `~/.config/mosaic/fleet/agents/` | `~/.mosaic/fleet/agents/` |
| `fleet/roles.local/` (overrides) | `~/.config/mosaic/fleet/roles.local/` | `~/.mosaic/fleet/roles.local/` |
| `fleet/profiles/` (working copies) | `~/.config/mosaic/fleet/profiles/` | `~/.mosaic/fleet/profiles/` |
| `fleet/roster.yaml`, `fleet/roles/` (baseline), `fleet/run/`, `fleet/services/` | `~/.config/mosaic/fleet/…` | unchanged (config home) |
Activation (`packages/mosaic/src/fleet/brain-home.ts`, mirrored in
`tools/fleet/start-agent-session.sh`):
1. `MOSAIC_BRAIN_HOME` env var — explicit, always wins.
2. Canonical `~/.mosaic` — adopted only when `MOSAIC_HOME` is the default
`~/.config/mosaic` AND `~/.mosaic/fleet/agents` exists. Custom
`--mosaic-home` values (tests, sandboxes, canaries) never adopt, keeping
them hermetic.
3. Otherwise the config home (legacy single-tree behavior).
Seat env dirs under a brain are subject to the same privacy boundary (0700
dirs, 0600 files); `.env.generated` files are structure-valuable and tracked
in the brain repo, hand-maintained `.env`/`.env.local` stay ignored and private.
## Examples
- `examples/minimal.yaml` starts one local canary slot.
@@ -255,68 +255,6 @@ fleet_declared_transport() {
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
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)"
@@ -335,8 +273,6 @@ check_fleet_transport() {
check_fleet_transport
check_brain_home
# Legacy migration surfaces should no longer contain symlink trees.
legacy_paths=(
"$HOME/.claude/agent-guides"
@@ -1,108 +0,0 @@
#!/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
run_case() {
# label, expect (ok|warn), 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
out=$(env "$@" bash -c "warn() { echo \"[WARN] \$*\"; }; 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)
if [[ "$expect" == ok && "$warns" -eq 0 ]]; then
echo "ok - $label"
elif [[ "$expect" == warn && "$warns" -gt 0 ]]; then
echo "ok - $label (warned)"
else
echo "output: $out" >&2
fail "$label: expected $expect (warns=$warns)"
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"
echo "ok - mosaic-doctor brain-home check"
@@ -80,26 +80,6 @@ safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME"
FLEET_DIR="$MOSAIC_HOME/fleet"
AGENT_ENV_DIR="$FLEET_DIR/agents"
# Brain-home split (canon docs/STRUCTURE-CANON.md §2): seat launch envs live
# under the brain home's fleet/agents when a brain is active; roster, roles
# baseline, and runtime state (fleet/run) stay under MOSAIC_HOME.
# Resolution mirrors packages/mosaic/src/fleet/brain-home.ts:
# 1. MOSAIC_BRAIN_HOME env (explicit, always wins)
# 2. ~/.mosaic — adopted only when MOSAIC_HOME is the default config home AND
# ~/.mosaic/fleet/agents exists
# 3. MOSAIC_HOME (legacy single-tree)
BRAIN_HOME="${MOSAIC_BRAIN_HOME:-}"
if [ -z "$BRAIN_HOME" ]; then
BRAIN_HOME="$MOSAIC_HOME"
if [ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" = "$HOME/.config/mosaic" ] \
&& [ -d "$HOME/.mosaic/fleet/agents" ]; then
BRAIN_HOME="$HOME/.mosaic"
fi
fi
if [ "$BRAIN_HOME" != "$MOSAIC_HOME" ]; then
AGENT_ENV_DIR="$BRAIN_HOME/fleet/agents"
fi
assert_managed_directory "$MOSAIC_HOME"
assert_managed_directory "$FLEET_DIR"
assert_private_directory "$AGENT_ENV_DIR"
@@ -167,54 +167,6 @@ if echo "$valid_args" | grep -qF 'bash -c'; then
fail "launcher constructed a shell command payload"
fi
# ── Brain-home split (canon §2) ─────────────────────────────────────────
# When MOSAIC_HOME is the default config home under $HOME and the host carries
# $HOME/.mosaic/fleet/agents, seat envs resolve from the brain tree; the config
# home still owns fleet/run (holder-owner) and remains a managed boundary.
: > "$TMUX_CALLS"
HOME_BRAIN="$ROOT/brain-home"
CONFIG_HOME="$HOME_BRAIN/.config/mosaic"
BRAIN="$HOME_BRAIN/.mosaic"
mkdir -p "$CONFIG_HOME/fleet/run" "$BRAIN/fleet/agents" "$HOME_BRAIN/work"
chmod 700 "$CONFIG_HOME" "$CONFIG_HOME/fleet" "$CONFIG_HOME/fleet/run" \
"$BRAIN/fleet/agents" "$HOME_BRAIN/work"
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$CONFIG_HOME/fleet/run/holder-owner"
chmod 600 "$CONFIG_HOME/fleet/run/holder-owner"
cat > "$BRAIN/fleet/agents/coder-brain.env.generated" <<EOF
MOSAIC_AGENT_NAME=coder-brain
MOSAIC_AGENT_CLASS=code
MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
MOSAIC_AGENT_REASONING=high
MOSAIC_AGENT_TOOL_POLICY=code
MOSAIC_AGENT_WORKDIR=$HOME_BRAIN/work
MOSAIC_TMUX_SOCKET=mosaic-test
EOF
chmod 600 "$BRAIN/fleet/agents/coder-brain.env.generated"
install_pane_binaries "$HOME_BRAIN"
HOME="$HOME_BRAIN" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID=$$ MOSAIC_TEST_HOME="$HOME_BRAIN" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$CONFIG_HOME" "$START" coder-brain
brain_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$brain_args" | grep -qF new-session || fail "brain-home generated projection did not reach tmux"
echo "$brain_args" | grep -qF 'coder-brain' || fail "brain-home agent env was not the launch source"
[ -f "$BRAIN/fleet/agents/coder-brain.env.generated" ] || fail "brain generated env vanished"
# Negative control: the SAME default-config-home shape but without
# ~/.mosaic/fleet/agents — the config-home env tree is used directly (legacy).
: > "$TMUX_CALLS"
HOME_NOBRAIN="$ROOT/brainless-home"
CONFIG_HOME_NOBRAIN="$HOME_NOBRAIN/.config/mosaic"
write_generated "$CONFIG_HOME_NOBRAIN" "coder-legacy"
install_pane_binaries "$HOME_NOBRAIN"
HOME="$HOME_NOBRAIN" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID=$$ MOSAIC_TEST_HOME="$HOME_NOBRAIN" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$CONFIG_HOME_NOBRAIN" "$START" coder-legacy
legacy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$legacy_args" | grep -qF new-session || fail "legacy single-tree launch regressed"
# The pane must start through an absolute clean-environment boundary. Its
# runtime command remains an argv vector, but no holder/session environment
# control variable can pass through the pane command.
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -1,6 +1,5 @@
import { readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fleetAgentEnvDir, fleetRolesLocalDir } from '../fleet/brain-home.js';
import type { Command } from 'commander';
import {
executeFleetAgentMutation,
@@ -150,9 +149,9 @@ async function executeCommand(
request,
mosaicHome,
rosterPath,
agentEnvDir: fleetAgentEnvDir(mosaicHome),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
rolesDir: join(mosaicHome, 'fleet', 'roles'),
overrideDir: fleetRolesLocalDir(mosaicHome),
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
dryRun: forceDryRun || opts.dryRun === true,
...(deps.projectionApplier === undefined ? {} : { projectionApplier: deps.projectionApplier }),
});
@@ -1,6 +1,5 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fleetAgentEnvDir, fleetRolesLocalDir } from '../fleet/brain-home.js';
import type { Command } from 'commander';
import {
parseV1MigrationObservations,
@@ -121,11 +120,11 @@ export function registerFleetMigrationCommand(
observations,
personaDirs: {
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
overrideDir: deps.overrideDir ?? fleetRolesLocalDir(mosaicHome),
overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
},
environment: {
mosaicHome,
agentEnvDir: fleetAgentEnvDir(mosaicHome),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
},
});
printJson(preview);
@@ -30,21 +30,19 @@ import { lstat, readFile, readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, isAbsolute, join, sep } from 'node:path';
import type { Command } from 'commander';
import { fleetRolesLocalDir } from '../fleet/brain-home.js';
function defaultMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
}
/** Baseline persona role contracts (reseeded on update; config home — framework). */
/** Baseline persona role contracts (reseeded on update). */
export function defaultRolesDir(mosaicHome = defaultMosaicHome()): string {
return join(mosaicHome, 'fleet', 'roles');
}
/** PRESERVE-protected override layer (survives update; wins on merge).
* Brain home (`~/.mosaic/fleet/roles.local`) when a brain is active. */
/** PRESERVE-protected override layer (survives update; wins on merge). */
export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string {
return fleetRolesLocalDir(mosaicHome);
return join(mosaicHome, 'fleet', 'roles.local');
}
/**
@@ -25,7 +25,6 @@ import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import type { Command } from 'commander';
import YAML from 'yaml';
import { fleetProfilesDir } from '../fleet/brain-home.js';
import {
defaultOverrideDir,
extractClassesFromDir,
@@ -37,10 +36,9 @@ function defaultMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
}
/** Directory holding the seeded profile yaml files — brain home when active
* (user working copies, committed), else the config home seed. */
/** Directory holding the seeded profile yaml files. */
export function defaultProfilesDir(mosaicHome = defaultMosaicHome()): string {
return fleetProfilesDir(mosaicHome);
return join(mosaicHome, 'fleet', 'profiles');
}
/** Directory holding the persona role contracts. */
@@ -3,7 +3,6 @@ import { homedir } from 'node:os';
import { join, relative, resolve } from 'node:path';
import type { Command } from 'commander';
import type { CommandRunner } from './fleet.js';
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
import {
applyPreparedGeneratedAgentEnvironmentProjection,
prepareGeneratedAgentEnvironmentProjection,
@@ -154,7 +153,7 @@ export async function executeFleetRegen(
options: FleetRegenOptions,
): Promise<FleetRegenResult> {
const mosaicHome = defaultMosaicHome(deps);
const agentEnvDir = fleetAgentEnvDir(mosaicHome);
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
const readRoster = deps.readRoster ?? defaultReadRoster(deps, mosaicHome);
const prepare = deps.prepareProjection ?? prepareGeneratedAgentEnvironmentProjection;
+1 -2
View File
@@ -13,7 +13,6 @@ import {
import { randomUUID } from 'node:crypto';
import { homedir, hostname, userInfo } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import * as readline from 'node:readline';
@@ -159,7 +158,7 @@ export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths
fleetToolsDir: join(mosaicHome, 'tools', 'fleet'),
tmuxToolsDir: join(mosaicHome, 'tools', 'tmux'),
systemdUserDir: join(homedir(), '.config', 'systemd', 'user'),
agentEnvDir: fleetAgentEnvDir(mosaicHome),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
};
}
@@ -349,90 +349,3 @@ describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
expect(mockExit).not.toHaveBeenCalled();
});
});
// ─── Seat harness homes (MOSAIC-D-002, brain-home split) ────────────────────
import { activeSeatDir, seatPersonaOverlay } from './launch.js';
describe('activeSeatDir — per-agent harness home resolution', () => {
let root: string;
const savedAgentName = process.env['MOSAIC_AGENT_NAME'];
const savedBrainHome = process.env['MOSAIC_BRAIN_HOME'];
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'mosaic-seat-home-'));
delete process.env['MOSAIC_BRAIN_HOME'];
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
if (savedAgentName === undefined) {
delete process.env['MOSAIC_AGENT_NAME'];
} else {
process.env['MOSAIC_AGENT_NAME'] = savedAgentName;
}
if (savedBrainHome !== undefined) {
process.env['MOSAIC_BRAIN_HOME'] = savedBrainHome;
} else {
delete process.env['MOSAIC_BRAIN_HOME'];
}
});
it('resolves the seat dir when MOSAIC_BRAIN_HOME carries the seat', () => {
const seat = join(root, 'brain', 'fleet', 'agents', 'coder0');
mkdirSync(seat, { recursive: true });
process.env['MOSAIC_AGENT_NAME'] = 'coder0';
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBe(seat);
});
it('returns undefined without an agent name (bare launches stay shared)', () => {
delete process.env['MOSAIC_AGENT_NAME'];
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined();
});
it('returns undefined when the seat dir does not exist in the brain', () => {
process.env['MOSAIC_AGENT_NAME'] = 'ghost';
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
mkdirSync(join(root, 'brain', 'fleet', 'agents'), { recursive: true });
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined();
});
it.each(['../escape', 'a/b', '.hidden-start', '', 'spaced name'])(
'rejects unsafe agent name %j (path traversal cannot leave the seat store)',
(name: string) => {
process.env['MOSAIC_AGENT_NAME'] = name;
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined();
},
);
it('seatPersonaOverlay renders the seat SOUL.md as an overlay block', () => {
const seat = join(root, 'brain', 'fleet', 'agents', 'coder0');
mkdirSync(seat, { recursive: true });
writeFileSync(join(seat, 'SOUL.md'), '# coder0 — code seat persona\n\nShips tested code.\n');
process.env['MOSAIC_AGENT_NAME'] = 'coder0';
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
const overlay = seatPersonaOverlay(join(root, 'config', 'mosaic'));
expect(overlay).toContain('## Seat Persona');
expect(overlay).toContain('coder0 — code seat persona');
});
it('seatPersonaOverlay is empty when the seat carries no SOUL.md', () => {
const seat = join(root, 'brain', 'fleet', 'agents', 'coder0');
mkdirSync(seat, { recursive: true });
process.env['MOSAIC_AGENT_NAME'] = 'coder0';
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
expect(seatPersonaOverlay(join(root, 'config', 'mosaic'))).toBe('');
});
it('seatPersonaOverlay is empty when no agent name is set', () => {
delete process.env['MOSAIC_AGENT_NAME'];
expect(seatPersonaOverlay(join(root, 'config', 'mosaic'))).toBe('');
});
});
+4 -49
View File
@@ -19,7 +19,7 @@ import {
import { createHash, randomBytes } from 'node:crypto';
import { createRequire } from 'node:module';
import { homedir, hostname } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { join, dirname } from 'node:path';
import type { Command } from 'commander';
import {
buildResolvedFleetCommsBlock,
@@ -29,7 +29,6 @@ import {
import { readRegularFileSecure } from '../fleet/secure-file.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
import { canonicalizeRoleClass } from './fleet-personas.js';
import { resolveBrainHome } from '../fleet/brain-home.js';
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
@@ -65,46 +64,9 @@ const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
opencode: 'XDG_CONFIG_HOME',
};
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime>.
* With an active brain seat (MOSAIC_AGENT_NAME + seat dir in the brain home)
* the home is per-agent instead: <brainHome>/fleet/agents/<seat>/.<runtime> —
* per-agent sessions, settings, and auth inside the seat dir (canon §2,
* MOSAIC-D-002). Seat runtime dirs are dot-named so the brain's ignore policy
* (per-seat .pi/.claude/.codex dirs) keeps credential material untracked. */
const SEAT_AGENT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
export function activeSeatDir(mosaicHome: string = MOSAIC_HOME): string | undefined {
const agent = process.env['MOSAIC_AGENT_NAME']?.trim();
if (
agent === undefined ||
agent === '' ||
!SEAT_AGENT_NAME_RE.test(agent) ||
agent.includes('..')
) {
return undefined;
}
const brain = resolveBrainHome(mosaicHome);
if (resolve(brain) === resolve(mosaicHome)) return undefined; // no brain
const seat = join(brain, 'fleet', 'agents', agent);
return existsSync(seat) ? seat : undefined;
}
function harnessHome(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string {
const seat = activeSeatDir(mosaicHome);
if (seat !== undefined) return join(seat, `.${runtime}`);
return join(mosaicHome, `.${runtime}`);
}
/** Seat persona block: with an active brain seat, <seat>/SOUL.md layers
* persona on the root generic base (canon invariant; MOSAIC-D-002). The base
* SOUL stays load-on-demand — only the seat delta is injected by value.
* Empty string when no seat is active or the seat carries no SOUL.md. */
export function seatPersonaOverlay(mosaicHome: string = MOSAIC_HOME): string {
const seatDir = activeSeatDir(mosaicHome);
if (seatDir === undefined) return '';
const seatSoul = readOptional(join(seatDir, 'SOUL.md'));
if (!seatSoul.trim()) return '';
return '## Seat Persona\n\n' + seatSoul.trim();
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
function harnessHome(runtime: RuntimeName): string {
return join(MOSAIC_HOME, `.${runtime}`);
}
/**
@@ -220,8 +182,6 @@ function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): v
cli_version: CLI_VERSION,
config_home: harnessHome(runtime),
config_home_isolated: true,
config_home_kind: activeSeatDir() !== undefined ? 'seat' : 'runtime-shared',
agent_name: process.env['MOSAIC_AGENT_NAME']?.trim() || null,
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
argv: redactArgv(cliArgs),
normative_fragments: normativeFragmentDigests(runtime),
@@ -609,11 +569,6 @@ For required push/merge/issue-close/release actions, execute without routine con
if (soulLocal.trim()) {
overlayBlocks.push('## Persona Overlay (SOUL.local.md)\n\n' + soulLocal.trim());
}
// Seat persona (MOSAIC-D-002): per-seat SOUL.md layers on the generic base.
const seatPersona = seatPersonaOverlay(mosaicHome);
if (seatPersona !== '') {
overlayBlocks.push(seatPersona);
}
const standardsLocal = readOptional(join(mosaicHome, 'STANDARDS.local.md'));
if (standardsLocal.trim()) {
overlayBlocks.push('## Standards Overlay (STANDARDS.local.md)\n\n' + standardsLocal.trim());
@@ -1,114 +0,0 @@
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
brainHomeIsActive,
fleetAgentEnvDir,
fleetProfilesDir,
fleetRolesLocalDir,
fleetStateDir,
resolveBrainHome,
type BrainHomeOptions,
} from './brain-home.js';
describe('fleet brain-home resolution', (): void => {
let cleanup: string | undefined;
const savedBrainEnv = process.env['MOSAIC_BRAIN_HOME'];
beforeEach((): void => {
delete process.env['MOSAIC_BRAIN_HOME'];
});
afterEach(async (): Promise<void> => {
if (savedBrainEnv === undefined) {
delete process.env['MOSAIC_BRAIN_HOME'];
} else {
process.env['MOSAIC_BRAIN_HOME'] = savedBrainEnv;
}
if (cleanup !== undefined) {
await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
}
});
async function makeTmp(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'mosaic-brain-home-'));
cleanup = root;
return root;
}
it('MOSAIC_BRAIN_HOME env wins over every other signal', (): void => {
process.env['MOSAIC_BRAIN_HOME'] = '/explicit/brain';
expect(resolveBrainHome('/any/mosaic-home')).toBe('/explicit/brain');
expect(fleetAgentEnvDir('/any/mosaic-home')).toBe('/explicit/brain/fleet/agents');
expect(brainHomeIsActive('/any/mosaic-home')).toBe(true);
});
it('injected envBrainHome wins identically (test seam)', (): void => {
const opts: BrainHomeOptions = { envBrainHome: '/injected/brain' };
expect(resolveBrainHome('/any/mosaic-home', opts)).toBe('/injected/brain');
expect(fleetAgentEnvDir('/any/mosaic-home', opts)).toBe('/injected/brain/fleet/agents');
});
it('a non-default mosaicHome never adopts the canonical brain (hermetic legacy)', (): void => {
const mosaicHome = '/tmp/not-the-default-config-home';
expect(resolveBrainHome(mosaicHome)).toBe(mosaicHome);
expect(brainHomeIsActive(mosaicHome)).toBe(false);
expect(fleetAgentEnvDir(mosaicHome)).toBe(join(mosaicHome, 'fleet', 'agents'));
});
it('the default config home adopts the brain when it carries fleet/agents', async (): Promise<void> => {
const root = await makeTmp();
const brain = join(root, 'brain');
await mkdir(join(brain, 'fleet', 'agents'), { recursive: true });
const configHome = join(root, 'config', 'mosaic');
const opts: BrainHomeOptions = { homes: { brain, configDefault: configHome } };
expect(resolveBrainHome(configHome, opts)).toBe(brain);
expect(fleetAgentEnvDir(configHome, opts)).toBe(join(brain, 'fleet', 'agents'));
expect(fleetRolesLocalDir(configHome, opts)).toBe(join(brain, 'fleet', 'roles.local'));
expect(fleetProfilesDir(configHome, opts)).toBe(join(brain, 'fleet', 'profiles'));
expect(fleetStateDir(configHome, opts)).toBe(join(brain, 'fleet'));
expect(brainHomeIsActive(configHome, opts)).toBe(true);
});
it('the default config home stays legacy when no brain exists', async (): Promise<void> => {
const root = await makeTmp();
const configHome = join(root, 'config', 'mosaic');
const opts: BrainHomeOptions = {
homes: { brain: join(root, 'brain'), configDefault: configHome },
};
expect(resolveBrainHome(configHome, opts)).toBe(configHome);
expect(brainHomeIsActive(configHome, opts)).toBe(false);
});
it('an empty MOSAIC_BRAIN_HOME is ignored, not treated as set', (): void => {
process.env['MOSAIC_BRAIN_HOME'] = ' ';
expect(resolveBrainHome('/tmp/legacy-home')).toBe('/tmp/legacy-home');
});
it('adoption requires fleet/agents specifically, not any brain content', async (): Promise<void> => {
const root = await makeTmp();
const brain = join(root, 'brain');
await mkdir(join(brain, 'fleet'), { recursive: true }); // fleet without agents
const configHome = join(root, 'config', 'mosaic');
const opts: BrainHomeOptions = { homes: { brain, configDefault: configHome } };
expect(resolveBrainHome(configHome, opts)).toBe(configHome);
});
it('real-home control: a host brain is adopted only through the default home', (): void => {
// Control on the un-injected path: this host carries ~/.mosaic/fleet/agents,
// so the default config home resolves to the brain or legacy — both valid
// canonical endpoints — while a non-default home never adopts.
const defaultHome = join(homedir(), '.config', 'mosaic');
const resolved = resolveBrainHome(defaultHome);
expect([defaultHome, join(homedir(), '.mosaic')]).toContain(resolved);
expect(resolveBrainHome(join(homedir(), 'elsewhere', 'mosaic'))).toBe(
join(homedir(), 'elsewhere', 'mosaic'),
);
});
});
-76
View File
@@ -1,76 +0,0 @@
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
/**
* Overridable resolution inputs (tests inject tmp homes; production reads
* the environment and the real home directory).
*/
export interface BrainHomeOptions {
/** Explicit brain home; defaults to `MOSAIC_BRAIN_HOME`. */
readonly envBrainHome?: string;
/**
* Canonical homes used for adoption. Defaults derive from the real
* `homedir()`: `{ brain: ~/.mosaic, configDefault: ~/.config/mosaic }`.
*/
readonly homes?: { readonly brain: string; readonly configDefault: string };
}
/**
* Brain-home resolution — the three-tree fleet split (stack canon
* `docs/STRUCTURE-CANON.md` §2, first carried by the USC estate brain):
*
* config home (~/.config/mosaic) framework templates + dispatch state:
* fleet/roles (baseline), fleet/roster.yaml,
* fleet/run (heartbeats), fleet/services
* brain home (~/.mosaic) user-owned fleet state, committed:
* fleet/agents/<seat>.env.*, fleet/roles.local,
* fleet/profiles working copies
*
* Resolution order:
* 1. `MOSAIC_BRAIN_HOME` env (explicit, always wins)
* 2. canonical `~/.mosaic` — adopted ONLY when mosaicHome is the real
* default config home AND `~/.mosaic/fleet/agents` exists. Custom
* `--mosaic-home` values (tests, sandboxes, canaries) never trigger
* adoption, keeping them hermetic and deterministic.
* 3. mosaicHome itself (legacy single-tree behavior).
*/
export function resolveBrainHome(mosaicHome: string, options: BrainHomeOptions = {}): string {
const explicit = options.envBrainHome ?? process.env['MOSAIC_BRAIN_HOME'];
if (explicit !== undefined && explicit.trim() !== '') {
return explicit;
}
const homes = options.homes ?? {
brain: join(homedir(), '.mosaic'),
configDefault: join(homedir(), '.config', 'mosaic'),
};
if (resolve(mosaicHome) !== resolve(homes.configDefault)) {
return mosaicHome;
}
return existsSync(join(homes.brain, 'fleet', 'agents')) ? homes.brain : mosaicHome;
}
/** True when fleet state resolves somewhere other than the config home. */
export function brainHomeIsActive(mosaicHome: string, options: BrainHomeOptions = {}): boolean {
return resolve(resolveBrainHome(mosaicHome, options)) !== resolve(mosaicHome);
}
/** Fleet state root (brain home when active, else the config home). */
export function fleetStateDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
return join(resolveBrainHome(mosaicHome, options), 'fleet');
}
/** Seat launch envs — `<brainHome>/fleet/agents` when a brain is active. */
export function fleetAgentEnvDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
return join(fleetStateDir(mosaicHome, options), 'agents');
}
/** PRESERVE-protected persona override layer — `<brainHome>/fleet/roles.local`. */
export function fleetRolesLocalDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
return join(fleetStateDir(mosaicHome, options), 'roles.local');
}
/** System-type profiles (user working copies) — `<brainHome>/fleet/profiles`. */
export function fleetProfilesDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
return join(fleetStateDir(mosaicHome, options), 'profiles');
}
@@ -3,7 +3,6 @@ import { lstat, open, readFile, unlink, type FileHandle } from 'node:fs/promises
import { randomUUID } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { fleetAgentEnvDir } from './brain-home.js';
import {
applyPreparedAgentEnvironmentProjection,
prepareAgentEnvironmentProjection,
@@ -618,7 +617,7 @@ function defaultPrepareProjections(
(agent: FleetRosterV2Agent): Promise<PreparedAgentEnvironmentProjection> =>
prepareAgentEnvironmentProjection({
mosaicHome,
agentEnvDir: fleetAgentEnvDir(mosaicHome),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
agentName: agent.name,
generated: projectRosterV2AgentGeneratedEnv(roster, agent),
}),
@@ -176,52 +176,6 @@ describe('generated fleet agent environment boundary', (): void => {
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
});
it('brain home: accepts and writes projections under MOSAIC_BRAIN_HOME/fleet/agents', async (): Promise<void> => {
const savedBrainHome = process.env['MOSAIC_BRAIN_HOME'];
try {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'config-home');
const brainHome = join(cleanup, 'brain');
const agentEnvDir = join(brainHome, 'fleet', 'agents');
process.env['MOSAIC_BRAIN_HOME'] = brainHome;
const result = await writeAgentEnvironmentProjection({
mosaicHome,
agentEnvDir,
agentName: 'coder0',
generated: generatedValues,
});
// Projection landed in the brain tree, not under the config home.
expect(result.generatedPath).toBe(join(agentEnvDir, 'coder0.env.generated'));
expect((await stat(join(brainHome, 'fleet'))).mode & 0o777).toBe(0o700);
expect((await stat(agentEnvDir)).mode & 0o777).toBe(0o700);
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
await expect(stat(join(mosaicHome, 'fleet'))).rejects.toThrow();
// A config-home agentEnvDir is now REJECTED while the brain is active —
// the boundary must not silently split state across two trees.
let rejected: unknown;
try {
await writeAgentEnvironmentProjection({
mosaicHome,
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
agentName: 'coder1',
generated: { ...generatedValues, MOSAIC_AGENT_NAME: 'coder1' },
});
} catch (caught: unknown) {
rejected = caught;
}
expect(rejected).toBeInstanceOf(AgentEnvBoundaryError);
} finally {
if (savedBrainHome === undefined) {
delete process.env['MOSAIC_BRAIN_HOME'];
} else {
process.env['MOSAIC_BRAIN_HOME'] = savedBrainHome;
}
}
});
it('regenerates desired keys, relocates safe legacy local data, and quarantines forbidden legacy input', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');
@@ -2,7 +2,6 @@ import { createHash, randomUUID } from 'node:crypto';
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fleetAgentEnvDir, resolveBrainHome } from './brain-home.js';
import { compareCodePoints } from './deterministic-order.js';
export type AgentEnvironmentKind = 'generated' | 'local';
@@ -529,15 +528,12 @@ async function validatePrivateProjectionDirectory(
mosaicHome: string,
agentEnvDir: string,
): Promise<void> {
// Brain-home split (canon §2): seat envs live under the brain home's
// fleet/agents when a brain is active; roster + templates stay config-home.
const expectedAgentEnvDir = fleetAgentEnvDir(mosaicHome);
const fleetDir = join(mosaicHome, 'fleet');
const expectedAgentEnvDir = join(fleetDir, 'agents');
if (resolve(agentEnvDir) !== resolve(expectedAgentEnvDir)) {
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', agentEnvDir);
}
const stateHome = resolveBrainHome(mosaicHome);
const fleetDir = join(stateHome, 'fleet');
await assertManagedDirectoryIfPresent(stateHome, false);
await assertManagedDirectoryIfPresent(mosaicHome, false);
await assertManagedDirectoryIfPresent(fleetDir, false);
await assertManagedDirectoryIfPresent(agentEnvDir, true);
}
@@ -547,9 +543,8 @@ async function ensurePrivateProjectionDirectory(
agentEnvDir: string,
): Promise<void> {
await validatePrivateProjectionDirectory(mosaicHome, agentEnvDir);
const stateHome = resolveBrainHome(mosaicHome);
const fleetDir = join(stateHome, 'fleet');
await ensureManagedDirectory(stateHome, false);
const fleetDir = join(mosaicHome, 'fleet');
await ensureManagedDirectory(mosaicHome, false);
await ensureManagedDirectory(fleetDir, false);
await ensureManagedDirectory(agentEnvDir, true);
}
@@ -0,0 +1,73 @@
# 2026-08-17 — Fleet identity, comms delivery, and the ~/.mosaic tree (continuation record)
> **Status:** active continuation record | **Owner:** Jason (rulings) / fleet (delivery) | **Created:** 2026-08-17, sb-it-1-dt session with Jarvis (jarvis-brain)
> **Audience:** the homelab agents continuing this effort tonight. Read this whole file before acting; it supersedes nothing but preserves structure and decisions that must not be lost.
---
## Why this exists
A session on sb-it-1-dt (2026-08-17) produced three architecture decisions (two awaiting Jason's ruling), one incident postmortem (#1295), interim guardrail edits in the user-owned `~/.mosaic/` contract tree, and one new tool (`ensure-watcher.sh`). The work spans jarvis-brain (P0, not retained) and this repo (the product). **This file is the stack-side anchor so continuation does not depend on jarvis-brain surviving.**
## 1. The `~/.mosaic` tree model — as-built, preserve this structure
Three-tree split (this is design intent, not accident; keep it through all framework work):
| Tree | Owner | Rule |
| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------- |
| `~/.config/mosaic/` | framework | upgrade-managed templates; NEVER user-edited; `mosaic upgrade` may overwrite |
| `~/.mosaic/` | user | working contracts, guides, fleet agents; upgrades reconcile with **deny-wins** (user edits never overwritten) |
| repo satellites | repos | bootstrapped per-repo `.mosaic/` state |
As-built inventory of `~/.mosaic` on sb-it-1-dt (2026-08-17):
- **Contract core:** `CONSTITUTION.md` (L0 law), `AGENTS.md` (dispatcher + guide router + Fleet Comms Watcher requirement), `SOUL.md` (generic base for ALL fleet agents, zero persona — includes the new **Fleet Boundaries** section), `STANDARDS.md` (universal standards — includes new **session identity** + **comms watcher hygiene** sections), `SYSTEM.md` (pure communication contract, byte-identical to jarvis-brain's prompt-testing `sr_opus_5_system_prompt.md`), `USER.md`, `TOOLS.md`.
- **`guides/`** — user-owned working copies (E2E-DELIVERY, ORCHESTRATOR(+PROTOCOL,+LEARNINGS), WAKE-DOCTRINE, VAULT-SECRETS, etc.).
- **`fleet/agents/`** — the per-agent store (this is MOSAIC-D-002's substrate, already in use):
- real agent dirs: `fargo/`, `orchestrator/`, `probe/`, `vision/`, `weekly-update/` — shape: `profile.json` (harness/account/overlay pointer) + `overlay.json` + `SOUL.md` (persona) + `scratch/` `work/` `notes/` subdirs (hygiene rules in root SOUL.md)
- `*.env.generated` launch overlays: `luna` `sol` `terra` (carry `MOSAIC_AGENT_NAME`, `_CLASS`, `_RUNTIME`, `_MODEL`, `_REASONING`, `_TOOL_POLICY`) — these are the mosaic-fleet seat launch envs; `inbox.env`, `itops.env` also present
- `probe/` is the validated layout proof: auth-bundle symlink chain, per-agent sessions, plugin-store symlink (from 2026-08-07)
- **`auth/`, `config/`, `memory/`, `plugins/`, `skills/`, `skills-local/`** — per-tree copies/links for runtime isolation.
- Related but outside the tree: watcher units at `~/.config/systemd/user/<agent>-comms-watcher.service`; watcher seen-state at `~/.local/state/comms-watcher-<agent>/`.
## 2. Decisions register (2026-08-17 session)
Full strict records live in jarvis-brain `docs/decisions/mosaic-stack/` (render on its dashboard); both are **Pending Jason's ruling**. Summaries so the content survives P0:
- **MOSAIC-D-001 — SYSTEM.md as canonical harness system prompt.** Static core (Constitution+AGENTS+USER+overlays) in one tracked file; launcher renders dynamic tail (mission/PRD/fleet/persona). Delivery: `--append-system-prompt` (repeatable) for pi/claude; symlinked core file for codex (`$CODEX_HOME/instructions.md`) and opencode (`AGENTS.md`); their dynamic tail via initial prompt (needs live verification). Static-first order is the cache win. `SYSTEM.md` in `~/.mosaic` today is the communication-contract file — D-001's SYSTEM.md is the broader composition; naming to reconcile at implementation.
- **MOSAIC-D-002 — per-agent harness homes + mechanical profiles.** Launch with targeted config-dir env vars (e.g. `PI_CODING_AGENT_DIR=~/.mosaic/fleet/agents/<name>/pi`), NOT literal HOME. SOUL.md identity mechanically generated from roster (single writer; kills the hand-copy drift measured in `agents/vision/SOUL.md` on jarvis-brain: declared Jarvis, answered Vision). Composes: SYSTEM core → per-agent SOUL → dynamic tail. `MOSAIC_AGENT_NAME` stays load-bearing for comms.
- **Comms delivery tooling belongs in the STACK framework, not jarvis-brain** (decided in discussion; supersedes the interim placement). jarvis-brain keeps only the transport _data_ (`comms/` tree) while it lives. Agents launch from their own repos (terra from `~/src/stack` etc.) — delivery is transport-repo-relative, so this works; but every installed watcher unit's ExecStart currently points into `~/src/jarvis-brain/scripts/` — that dependency is the P0 trap to remove. Migration = move tools + regenerate units, in one step.
- **Watcher provisioning is instantiation duty, never running-agent duty.** Interim landed as jarvis-brain `scripts/comms/ensure-watcher.sh` (idempotent ensure + `--status` boot check + interim identity warnings: missing target session, pane `MOSAIC_AGENT_NAME` mismatch via `/proc/<child>/environ`, bare-runtime NOTE). Framework move: fold into `mosaic agent --new` + fleet launch + `mosaic doctor` drift check.
- **Prose guardrails landed (interim fences until mechanical fixes):** `~/.mosaic/SOUL.md` Fleet Boundaries (wrong-session tripwire; comms ownership; cross-agent investigation requires tasking) · `STANDARDS.md` session identity + watcher hygiene · `AGENTS.md` Fleet Comms Watcher requirement (P0-interim script path marked transitional).
## 3. Incident → #1295 (already tracked here)
`https://git.mosaicstack.dev/mosaicstack/stack/issues/1295` — docs-seat incident: cwd-keyed session files served three lives (dev chat → goals seat → 22 watcher injections into a wedged process); name-based watcher delivery with no identity verification; wedge after pi 0.84.1→0.84.2 update passes every liveness instrument. Proposed fixes enumerated there; provisioning follow-up in comment ID 23027.
## 4. Open work queue (suggested sequence)
1. **Comms tooling migration PR** (lane `next`): move `comms-watcher.sh`, `install-watcher.sh`, `ensure-watcher.sh` into the framework tree → deploy `~/.config/mosaic/tools/comms/`; regenerate existing units' ExecStart to framework paths (one-command sweep); keep `COMMS_WATCH_REPO` per-host config (points at a brain checkout until the queue transport lands). Reference: jarvis-brain commit `701c353b1`.
2. **Ensure-on-instantiation**: `mosaic agent --new` / fleet launch call ensure semantics; `mosaic doctor` gains the drift check (`--status --all` semantics + `fred`'s hand-written unit as the known drift case; also note daphne/docs/happy/pepper/sanity/tiny/fargo currently have no watcher — cover or consciously exempt).
3. **MOSAIC-D-002 implementation** (after ruling): per-agent homes via targeted env vars; roster-generated SOUL.md single-writer; extend the existing `*.env.generated` pattern; launch ledger keeps `config_home` audit.
4. **MOSAIC-D-001 implementation** (after ruling): SYSTEM.md sourcing + per-harness delivery + `compose-contract` becomes render-core+tail with drift check; bench cache-ordering before/after (jarvis-brain `domains/software-dev/mosaic-stack/prompt-testing` has the bench).
5. **Queue transport + forced separation** (longer term): supersedes watcher path; identity-verification and wedge-detection remain valid regardless of transport.
6. **Docs inheritance**: jarvis-brain AGENTS.md's durable comms guidance (E7 pi-glyph delivery gotchas, capture-pane rules, comms protocol) must be inherited into stack docs before P0 retirement.
## 5. Rules for tonight's agents
- Lane: **`next`** only; nothing to `main` without Jason (standing ruling).
- Attribution caveat #1280: Gitea/git identity from this host may misattribute (issue #1295 showed as created by `@mos-dt-0`); prefer per-invocation `git -c user.name=<seat>` and verify what the remote recorded.
- Do not delete `sb-it-1-dt:docs]` (untracked file at repo root) — it is cited fleet-wide as incident evidence.
- Edit user contracts in `~/.mosaic/`, never the templates in `~/.config/mosaic/`.
- Do not restart other fleet seats unilaterally (goals/scrappy/sanity restart decisions are fred's/Jason's per the docs-seat report).
## 6. Artifact map
| Artifact | Where |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| Decision records D-001/D-002 (strict, pending ruling) | jarvis-brain `docs/decisions/mosaic-stack/2026-08-17_mosaic-d-00{1,2}_*.md` |
| Incident issue + provisioning comment | stack #1295 + comment 23027 |
| ensure-watcher.sh (reference implementation) | jarvis-brain `scripts/comms/ensure-watcher.sh` (commit `701c353b1`) |
| Bench for prompt A/B (pi, thinking levels, footer-token semantics) | jarvis-brain `domains/software-dev/mosaic-stack/prompt-testing/` + `docs/reports/2026-08-17-prompt-testing-glm-bench.md` |
| Launcher inspection basis | `@mosaicstack/mosaic` 0.0.49 `dist/commands/launch.js` (composeContract / ensureRuntimeConfig / harness-home isolation) |
| Guardrail edits | `~/.mosaic/{SOUL,AGENTS,STANDARDS}.md` on sb-it-1-dt (2026-08-17 16:5317:04) |