From 8eb81ebec189a3be3d9579b7ae3d5e7dd8021c28 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 3 Sep 2026 12:30:42 -0500 Subject: [PATCH] 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) --- scripts/agent.sh | 8 +++ scripts/common.sh | 9 ++-- scripts/onboard.sh | 124 +++++++++++++++++++++++++++++++++++++++++++ scripts/run-task.sh | 4 ++ scripts/test-task.sh | 35 ++++++++++++ templates/USER.md | 29 ++++++++++ 6 files changed, 203 insertions(+), 6 deletions(-) create mode 100755 scripts/onboard.sh create mode 100644 templates/USER.md diff --git a/scripts/agent.sh b/scripts/agent.sh index 3503c041..787685fb 100755 --- a/scripts/agent.sh +++ b/scripts/agent.sh @@ -41,6 +41,14 @@ load_config load_release 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//agent.json exists it is # strictly validated and its values become defaults (CLI flags override). # The seat's SOUL.md overrides the contract persona; governance contracts diff --git a/scripts/common.sh b/scripts/common.sh index 30369d03..8bffd368 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -50,11 +50,8 @@ bootstrap_runtime_dir() { echo "bootstrap: created $MOSAIC_DEV_DIR" fi touch "$MOSAIC_DEV_DIR/$POC_ROOT_MARKER" - # Live user context layer (M14): seeded once, owned by the user from - # then on; dispatched to every agent launch without rebuilds. + # Live user context layer (M14/M16): the directory is system-managed, + # 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" - 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 } diff --git a/scripts/onboard.sh b/scripts/onboard.sh new file mode 100755 index 00000000..616c1dce --- /dev/null +++ b/scripts/onboard.sh @@ -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" diff --git a/scripts/run-task.sh b/scripts/run-task.sh index 53b67b56..4aac0a08 100755 --- a/scripts/run-task.sh +++ b/scripts/run-task.sh @@ -14,4 +14,8 @@ load_config load_release # compose requires MOSAIC_IMAGE_TAG; task runs are release-scoped too 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 "$@" diff --git a/scripts/test-task.sh b/scripts/test-task.sh index 82e16091..b269bfdb 100755 --- a/scripts/test-task.sh +++ b/scripts/test-task.sh @@ -352,6 +352,41 @@ else echo "skip live task cases (docker unavailable)" fi +# ---------- onboarding (M16): deterministic, no Docker ---------- +ONB="$SANDBOX/data" +cat > "$SANDBOX/onb-config.json" < "$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 "selftest: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ] diff --git a/templates/USER.md b/templates/USER.md new file mode 100644 index 00000000..db4e5e27 --- /dev/null +++ b/templates/USER.md @@ -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}}