feat(onboard): user onboarding - no default USER.md, guided creation (#37)
- bootstrap no longer creates user/USER.md (owner direction) - scripts/onboard.sh: name REQUIRED (interactive loop or --name), optional fields prompted (profession, marital, age, gender, education, location, timezone, skillset, interests, hobbies, pets); flag-driven non-interactive mode for automation - templates/USER.md: canon skeleton, placeholder rendering, unfilled optional = (not provided) - agent.sh: auto-runs onboarding when profile missing (TTY gate); headless run-task warns and continues without user context - user profile dispatched to all launches (M14 layer) Closes #37 (onboarding requirements from owner layout review)
This commit is contained in:
@@ -41,6 +41,14 @@ load_config
|
|||||||
load_release
|
load_release
|
||||||
bootstrap_runtime_dir
|
bootstrap_runtime_dir
|
||||||
|
|
||||||
|
# Onboarding gate (M16): a TUI agent cannot launch without a user profile.
|
||||||
|
# The onboarding wizard runs automatically here - the TTY is already yours.
|
||||||
|
if [ ! -f "$MOSAIC_DEV_DIR/user/USER.md" ]; then
|
||||||
|
echo "agent: no user profile found - starting onboarding"
|
||||||
|
scripts/onboard.sh
|
||||||
|
[ -f "$MOSAIC_DEV_DIR/user/USER.md" ] || { echo "agent: onboarding did not complete; aborting launch" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
# Agent seat definition (M15): when agents/<name>/agent.json exists it is
|
# Agent seat definition (M15): when agents/<name>/agent.json exists it is
|
||||||
# strictly validated and its values become defaults (CLI flags override).
|
# strictly validated and its values become defaults (CLI flags override).
|
||||||
# The seat's SOUL.md overrides the contract persona; governance contracts
|
# The seat's SOUL.md overrides the contract persona; governance contracts
|
||||||
|
|||||||
+3
-6
@@ -50,11 +50,8 @@ bootstrap_runtime_dir() {
|
|||||||
echo "bootstrap: created $MOSAIC_DEV_DIR"
|
echo "bootstrap: created $MOSAIC_DEV_DIR"
|
||||||
fi
|
fi
|
||||||
touch "$MOSAIC_DEV_DIR/$POC_ROOT_MARKER"
|
touch "$MOSAIC_DEV_DIR/$POC_ROOT_MARKER"
|
||||||
# Live user context layer (M14): seeded once, owned by the user from
|
# Live user context layer (M14/M16): the directory is system-managed,
|
||||||
# then on; dispatched to every agent launch without rebuilds.
|
# but USER.md is NEVER auto-created. Onboarding (scripts/onboard.sh,
|
||||||
|
# auto-invoked by agent.sh) creates it from the template.
|
||||||
mkdir -p "$MOSAIC_DEV_DIR/user"
|
mkdir -p "$MOSAIC_DEV_DIR/user"
|
||||||
if [ ! -f "$MOSAIC_DEV_DIR/user/USER.md" ]; then
|
|
||||||
printf '# User\n\nDescribe yourself, your machine, and your preferences here.\nThis file is dispatched to every Mosaic agent launch.\n' \
|
|
||||||
> "$MOSAIC_DEV_DIR/user/USER.md"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+124
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# User onboarding: renders the user profile (user/USER.md) from the canon
|
||||||
|
# template. User name is REQUIRED; everything else is optional at this time
|
||||||
|
# (skillset is intended to be filled ongoing by agents in later layers).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/onboard.sh # interactive prompts (TTY)
|
||||||
|
# scripts/onboard.sh --name "Jason" [--timezone "Europe/Berlin"] [...]
|
||||||
|
#
|
||||||
|
# Fields: name (required), profession, marital-status, age, gender,
|
||||||
|
# education, location, timezone, skillset, interests, hobbies, pets
|
||||||
|
#
|
||||||
|
# The rendered profile is dispatched to every agent launch. Re-running
|
||||||
|
# onboarding replaces the profile (the previous file is kept as .bak).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
# shellcheck source=common.sh
|
||||||
|
source scripts/common.sh
|
||||||
|
|
||||||
|
load_config
|
||||||
|
load_release
|
||||||
|
bootstrap_runtime_dir
|
||||||
|
|
||||||
|
TEMPLATE="templates/USER.md"
|
||||||
|
OUT_DIR="$MOSAIC_DEV_DIR/user"
|
||||||
|
OUT="$OUT_DIR/USER.md"
|
||||||
|
[ -f "$TEMPLATE" ] || { echo "onboard: template missing: $TEMPLATE" >&2; exit 4; }
|
||||||
|
|
||||||
|
NAME="" PROFESSION="" MARITAL_STATUS="" AGE="" GENDER="" EDUCATION=""
|
||||||
|
LOCATION="" TIMEZONE="" SKILLSET="" INTERESTS="" HOBBIES="" PETS=""
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--name) NAME="${2:-}"; shift 2 ;;
|
||||||
|
--profession) PROFESSION="${2:-}"; shift 2 ;;
|
||||||
|
--marital-status) MARITAL_STATUS="${2:-}"; shift 2 ;;
|
||||||
|
--age) AGE="${2:-}"; shift 2 ;;
|
||||||
|
--gender) GENDER="${2:-}"; shift 2 ;;
|
||||||
|
--education) EDUCATION="${2:-}"; shift 2 ;;
|
||||||
|
--location) LOCATION="${2:-}"; shift 2 ;;
|
||||||
|
--timezone) TIMEZONE="${2:-}"; shift 2 ;;
|
||||||
|
--skillset) SKILLSET="${2:-}"; shift 2 ;;
|
||||||
|
--interests) INTERESTS="${2:-}"; shift 2 ;;
|
||||||
|
--hobbies) HOBBIES="${2:-}"; shift 2 ;;
|
||||||
|
--pets) PETS="${2:-}"; shift 2 ;;
|
||||||
|
*) echo "onboard: unknown option: $1" >&2; exit 4 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
INTERACTIVE="no"
|
||||||
|
if [ -t 0 ] && [ -t 1 ]; then INTERACTIVE="yes"; fi
|
||||||
|
|
||||||
|
trim() { local v="$1"; v="${v#"${v%%[![:space:]]*}"}"; v="${v%"${v##*[![:space:]]}"}"; printf '%s' "$v"; }
|
||||||
|
valid_value() { local v="$1"; [ -n "$v" ] && [ "${#v}" -le 500 ] && [ "${v//$'\n'/}" = "$v" ] && [ "${v//$'\r'/}" = "$v" ]; }
|
||||||
|
|
||||||
|
# Required: name
|
||||||
|
if [ -z "$(trim "$NAME")" ]; then
|
||||||
|
if [ "$INTERACTIVE" = "yes" ]; then
|
||||||
|
while [ -z "$(trim "$NAME")" ]; do
|
||||||
|
read -r -p "Your name (required): " NAME || NAME=""
|
||||||
|
NAME="$(trim "$NAME")"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "onboard: user name is required (use --name \"...\", or run from a terminal)" >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
NAME="$(trim "$NAME")"
|
||||||
|
valid_value "$NAME" || { echo "onboard: invalid name" >&2; exit 2; }
|
||||||
|
|
||||||
|
# Optional fields: prompt interactively when not supplied by flag
|
||||||
|
ask_optional() { # varname label current
|
||||||
|
local __label="$2" __val=""
|
||||||
|
if [ -z "$(trim "$3")" ] && [ "$INTERACTIVE" = "yes" ]; then
|
||||||
|
read -r -p "$__label (optional, Enter to skip): " __val || __val=""
|
||||||
|
__val="$(trim "$__val")"
|
||||||
|
if valid_value "$__val"; then printf '%s' "$__val"; return; fi
|
||||||
|
printf '%s' "$3"
|
||||||
|
else
|
||||||
|
printf '%s' "$3"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
PROFESSION="$(ask_optional PROFESSION "Profession" "$PROFESSION")"
|
||||||
|
MARITAL_STATUS="$(ask_optional MARITAL_STATUS "Marital status" "$MARITAL_STATUS")"
|
||||||
|
AGE="$(ask_optional AGE "Age" "$AGE")"
|
||||||
|
GENDER="$(ask_optional GENDER "Gender" "$GENDER")"
|
||||||
|
EDUCATION="$(ask_optional EDUCATION "Education level" "$EDUCATION")"
|
||||||
|
LOCATION="$(ask_optional LOCATION "Location" "$LOCATION")"
|
||||||
|
TIMEZONE="$(ask_optional TIMEZONE "Timezone" "$TIMEZONE")"
|
||||||
|
SKILLSET="$(ask_optional SKILLSET "Skillset" "$SKILLSET")"
|
||||||
|
INTERESTS="$(ask_optional INTERESTS "Interests" "$INTERESTS")"
|
||||||
|
HOBBIES="$(ask_optional HOBBIES "Hobbies" "$HOBBIES")"
|
||||||
|
PETS="$(ask_optional PETS "Pets" "$PETS")"
|
||||||
|
|
||||||
|
for pair in "profession:$PROFESSION" "marital:$MARITAL_STATUS" "age:$AGE" "gender:$GENDER" "education:$EDUCATION" "location:$LOCATION" "timezone:$TIMEZONE" "skillset:$SKILLSET" "interests:$INTERESTS" "hobbies:$HOBBIES" "pets:$PETS"; do
|
||||||
|
val="${pair#*:}"
|
||||||
|
[ -n "$(trim "$val")" ] || continue # empty optional fields are valid
|
||||||
|
valid_value "$(trim "$val")" || { echo "onboard: invalid value for ${pair%%:*}" >&2; exit 2; }
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
# Preserve the previous profile before replacing it (onboard re-runs are
|
||||||
|
# deliberate, but the old copy costs nothing to keep).
|
||||||
|
[ -f "$OUT" ] && cp -f "$OUT" "$OUT_DIR/USER.md.bak"
|
||||||
|
|
||||||
|
# Render: template placeholders -> values; unfilled -> "(not provided)".
|
||||||
|
# Node keeps the rendering exact regardless of slashes/& in values.
|
||||||
|
NAME="$NAME" PROFESSION="$PROFESSION" MARITAL_STATUS="$MARITAL_STATUS" AGE="$AGE" \
|
||||||
|
GENDER="$GENDER" EDUCATION="$EDUCATION" LOCATION="$LOCATION" TIMEZONE="$TIMEZONE" \
|
||||||
|
SKILLSET="$SKILLSET" INTERESTS="$INTERESTS" HOBBIES="$HOBBIES" PETS="$PETS" \
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const t = fs.readFileSync(process.argv[1], "utf8");
|
||||||
|
const v = process.env;
|
||||||
|
const out = t.replace(/\{\{(\w+)\}\}/g, (m, k) => {
|
||||||
|
const val = v[k];
|
||||||
|
return val === undefined || val === "" ? "(not provided)" : val;
|
||||||
|
});
|
||||||
|
fs.writeFileSync(process.argv[2], out);
|
||||||
|
' "$TEMPLATE" "$OUT.new"
|
||||||
|
|
||||||
|
mv -f "$OUT.new" "$OUT"
|
||||||
|
echo "onboard: user profile written: $OUT"
|
||||||
|
echo "onboard: dispatched to every agent launch; edit freely or re-run onboard"
|
||||||
@@ -14,4 +14,8 @@ load_config
|
|||||||
load_release # compose requires MOSAIC_IMAGE_TAG; task runs are release-scoped too
|
load_release # compose requires MOSAIC_IMAGE_TAG; task runs are release-scoped too
|
||||||
bootstrap_runtime_dir
|
bootstrap_runtime_dir
|
||||||
|
|
||||||
|
if [ ! -f "$MOSAIC_DEV_DIR/user/USER.md" ]; then
|
||||||
|
echo "run-task: note: user profile not onboarded - continuing without user context (scripts/onboard.sh)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
exec node scripts/mosaic-task.mjs "$@"
|
exec node scripts/mosaic-task.mjs "$@"
|
||||||
|
|||||||
@@ -352,6 +352,41 @@ else
|
|||||||
echo "skip live task cases (docker unavailable)"
|
echo "skip live task cases (docker unavailable)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---------- onboarding (M16): deterministic, no Docker ----------
|
||||||
|
ONB="$SANDBOX/data"
|
||||||
|
cat > "$SANDBOX/onb-config.json" <<EOF
|
||||||
|
{"configVersion":1,"environment":"development","dataRoot":"$ONB","execution":{"backend":"docker","provider":"zai","model":"m"}}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
expect_exit "onboard without name exits 4 (non-interactive)" 4 -- \
|
||||||
|
env MOSAIC_CONFIG="$SANDBOX/onb-config.json" scripts/onboard.sh --profession x
|
||||||
|
expect_exit "onboard --name renders profile" 0 -- \
|
||||||
|
env MOSAIC_CONFIG="$SANDBOX/onb-config.json" scripts/onboard.sh --name "Jason" --timezone "Europe/Berlin"
|
||||||
|
[ -f "$ONB/user/USER.md" ] \
|
||||||
|
&& check "profile written" 0 || check "profile written" 1
|
||||||
|
grep -q "name: Jason" "$ONB/user/USER.md" \
|
||||||
|
&& grep -q "(not provided)" "$ONB/user/USER.md" \
|
||||||
|
&& check "canon structure: required filled, optional placeholdered" 0 \
|
||||||
|
|| check "canon structure: required filled, optional placeholdered" 1
|
||||||
|
grep -q "## Skillset" "$ONB/user/USER.md" && grep -q "## Pets" "$ONB/user/USER.md" \
|
||||||
|
&& check "canon sections present" 0 || check "canon sections present" 1
|
||||||
|
|
||||||
|
# Live recall: real pi, real phrasing - assert containment, not exactness
|
||||||
|
printf '{"taskVersion":1,"id":"t-user","prompt":"What is the user name? Reply with only the name.","session":"onb-check","timeoutSeconds":180}' > "$SANDBOX/recall-task.json"
|
||||||
|
RC=0
|
||||||
|
scripts/run-task.sh run "$SANDBOX/recall-task.json" >/dev/null 2>&1 || RC=$?
|
||||||
|
[ "$RC" -eq 0 ] && check "user recall run succeeds" 0 || check "user recall run succeeds (exit $RC)" 1
|
||||||
|
FR="$(ls -dt "$SANDBOX/data/runs"/r-* | head -1)"
|
||||||
|
RESP="$(node -e 'try{const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(r.response||"")}catch{console.log("")}' "$FR/result.json" 2>/dev/null)"
|
||||||
|
echo "$RESP" | grep -qi "jason" \
|
||||||
|
&& check "recalled user name (response: $RESP)" 0 || check "recalled user name (response: $RESP)" 1
|
||||||
|
# headless run must NOT carry an agent identity section
|
||||||
|
! grep -q 'AGENT IDENTITY' "$SANDBOX/data/system-prompt.md" 2>/dev/null \
|
||||||
|
&& check "no agent identity on headless run" 0 || check "no agent identity on headless run" 1
|
||||||
|
|
||||||
|
git -C "$SANDBOX/repo" rev-parse >/dev/null 2>&1 || true
|
||||||
|
unset MOSAIC_CONFIG
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "selftest: $PASS passed, $FAIL failed"
|
echo "selftest: $PASS passed, $FAIL failed"
|
||||||
[ "$FAIL" -eq 0 ]
|
[ "$FAIL" -eq 0 ]
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# User
|
||||||
|
|
||||||
|
name: {{NAME}}
|
||||||
|
profession: {{PROFESSION}}
|
||||||
|
location: {{LOCATION}}
|
||||||
|
timezone: {{TIMEZONE}}
|
||||||
|
|
||||||
|
## Demographics
|
||||||
|
|
||||||
|
marital status: {{MARITAL_STATUS}}
|
||||||
|
age: {{AGE}}
|
||||||
|
gender: {{GENDER}}
|
||||||
|
education: {{EDUCATION}}
|
||||||
|
|
||||||
|
## Skillset
|
||||||
|
|
||||||
|
{{SKILLSET}}
|
||||||
|
|
||||||
|
## Interests
|
||||||
|
|
||||||
|
{{INTERESTS}}
|
||||||
|
|
||||||
|
## Hobbies
|
||||||
|
|
||||||
|
{{HOBBIES}}
|
||||||
|
|
||||||
|
## Pets
|
||||||
|
|
||||||
|
{{PETS}}
|
||||||
Reference in New Issue
Block a user